https://lafor.ge/feed.xml

Partie 13 : UTF-8 et échappement de caractères

2025-01-06
Les articles de la série

Bonjour à toutes et tous 😃

Aujourd'hui on va s'attaquer aux deux problèmes les plus relous du parsing quand on veut manipuler des chaîne de caractères.

Gérer de l'UTF-8 et gérer du JSON et donc de l'échappement des caractères.

Cet article va être un plus détente que les précédents.

On va en profiter pour corriger quelques problèmes.

Correction du Forecaster

La première chose que nous allons faire c'est réparé le Forecaster qui a quelques problèmes qui vont devenir dramatiques si on les laissent en l'état.

Notre API actuelle de Forecaster est la suivante:

Forecaster::new(scanner)
    .try_or(UntilToken(Token::Operator(Operator::And)))?
    .try_or(UntilToken(Token::Operator(Operator::Or)))?
    .finish()
    .ok_or(ParseError::UnexpectedToken)?;

Son mode de fonctionnement est un peu idiot: on prédit le première élément, puis si on ne trouve pas le suivant, et ainsi de suite.

Sauf que ça pose un problème. Par exemple cette requête va faire déconner le parse.

field1 = 12 OR field2 = 45 AND field3 = 6

On voudrais que le groupe prédit soit

field1 = 12

Sauf que le forecaster lui recherche AND en premier, et il va le trouver. Mais trop loin. Du coup le groupe prédit réellement récupéré devient:

field1 = 12 OR field2 = 45 

Et donc ça signifie que l'opérateur que l'on voulait vraiment prédire c'était OR et AND.

Et bien faisons-le !

prdiction   AND  : field1 = 12 OR field2 = 45 
prédiction  OR   : field1 = 12

Nous voulons la prédiction la plus courte, car cela signifie que l'opérateur a été détecté en premier

prdiction   AND  : taille = 26
prédiction  OR   : taille = 11

Donc notre prédiction est sur OR et est "field1 = 12"

Mais attention! Si la requête ne possède pas l'opérateur, alors il faut en éliminer le résultat de la comparaison de taille de prédiction.

Par exemple, avec la requête

field2 = 45 AND field3 = 6

Nos prédictions donnent:

prdiction   AND  : field2 = 45   =>  taille : 11
prédiction  OR   : <None>

Notre groupe prédit sera "field2 = 45".

Construisons notre système plus fialble et correct.

Notre nouvelle API va être la suivante:

Forecaster::new(scanner)
    // ajout des prédictions
    .add_forecastable(UntilToken(Token::Operator(Operator::And)))
    .add_forecastable(UntilToken(Token::Operator(Operator::Or)))
    // prédictions
    .forecast()?
    .ok_or(ParseError::UnexpectedToken)?;

La différence par rapport à l'ancienne version, est que l'on n'a pas de coupe circuit sur la reconnaissance du premier élément, on a 3 parties.

  • construction du Forecaster à partir d'un Scanner
  • ajout des potentielle prédiction
  • application des prédictions et récupération de la plus courte, si elle existe

Notre nouveau Forecaster sera celui-ci.

pub struct Forecaster<'a, 'b, T, S> {
    scanner: &'b mut Scanner<'a, T>,
    forcastables: Vec<Box<dyn Forecast<'a, T, S>>>,
}

impl<'a, 'b, T, S> Forecaster<'a, 'b, T, S> {
    pub fn new(scanner: &'b mut Scanner<'a, T>) -> Self {
        Self {
            scanner,
            forcastables: vec![],
        }
    }
}

Le Box<dyn Forecast<'a, T, S>> est nécessaire car nous voulons pouvoir accumuler des Forecast de tailles différentes. Le seul moyen est de créer cette indirection.

L'ajout des pédictions est un simple ajout dans un Vec.

impl<'a, T, S> Forecaster<'a, '_, T, S> {
    /// Add new [Forecast] element to the forecasting pool
    pub fn add_forecastable<F: Forecast<'a, T, S> + 'static>(mut self, forecastable: F) -> Self {
        self.forcastables.push(Box::new(forecastable));
        self
    }
}

Le processus est chaînable car il renvoie self.

L'implémentation de l'algorithme de récupération de la prédiction potentielle la plus courte est la suivante.

impl<'a, T, S> Forecaster<'a, '_, T, S> {
    /// Run the [Forecast] pool, find the minimal group
    pub fn forecast(self) -> parser::Result<Option<Forecasting<'a, T, S>>> {
        let mut result = None;
        // on boucle sur les possibilités de prédictions
        for forecastable in self.forcastables.into_iter() {
            // on tente de prédire l'élément
            match forecastable.forecast(self.scanner)? {
                // si l'on a trouvé quelque chose
                ForecastResult::Found {
                    start,
                    end,
                    end_slice,
                } => {
                    // on récupère le groupe prédit
                    let data = &self.scanner.remaining()[..end_slice];
                    let new_forecast = Forecasting { start, end, data };
                    match &result {
                        // si l'on n'a encore rien prédit du tout
                        None => {
                            // le groupe trouvé devient le résultat
                            result = Some(new_forecast);
                        }
                        // s'il y a déjà une prédiction
                        Some(min_forecast) => {
                            // on compare la taille du groupe trouvé par rapport
                            // à celui déjà trouvé
                            if new_forecast.data.len() < min_forecast.data.len() {
                                // il devient alors le nouveau groupe prédit
                                result = Some(new_forecast);
                            }
                        }
                    }
                }
                // si la prédiction échoue, on ne fait rien
                ForecastResult::NotFound => {}
            }
        }
        Ok(result)
    }
}

Petit test parce que l'on est jamais trop prudent.

#[test]
fn test_forecasting() {
    let data = b"field1 = 12 OR field2 = 45 AND field3 = 6";
    let mut scanner = Tokenizer::new(data);
    let forecasting = Forecaster::new(&mut scanner)
        .add_forecastable(UntilToken(Token::Operator(Operator::And)))
        .add_forecastable(UntilToken(Token::Operator(Operator::Or)))
        .forecast()
        .unwrap()
        .unwrap();
    assert_eq!(
        forecasting,
        Forecasting {
            start: Token::Operator(Operator::Or),
            end: Token::Operator(Operator::Or),
            data: b"field1 = 12 "
        }
    );

    let data = b"field2 = 45 AND field3 = 6";
    let mut scanner = Tokenizer::new(data);
    let forecasting = Forecaster::new(&mut scanner)
        .add_forecastable(UntilToken(Token::Operator(Operator::And)))
        .add_forecastable(UntilToken(Token::Operator(Operator::Or)))
        .forecast()
        .unwrap()
        .unwrap();
    assert_eq!(
        forecasting,
        Forecasting {
            start: Token::Operator(Operator::And),
            end: Token::Operator(Operator::And),
            data: b"field2 = 45 "
        }
    );
}

Nickel! Une bonne choses de faite! 😎

Parser de l'UTF-8

Et là vous êtes en train de vous dire: "mais c'est de l'arnaque cet article, il sensé parler d'UTF-8 et de JSON et on a fait tout un laius sur la prédiction".

Et vous avez raison.

Mais on a fait ce crochet sur la réparation de notre Forecaster car nous allons en avoir besoin maintenant, et il vaudrait mieux qu'il fonctionne correctement.

Notre approche actuelle de reconnaissance des chaîne de caractères est plutôt archaïque et rudimentaire.

C'est également très limité. Nous nous sommes restreint à l'ASCII en délégant la détection aux méthodes is_ascii_alphanumeric | is_ascii_punctuation | is_ascii_whitespace.

Dès que l'on n'est plus dans de l'ASCII, on a terminé la reconnaissance de la chaîne.

Je vais faire pas mal d'aller retour dans le code car les modifications sont éparses.

Comme à chaque fois, à la fin de l'article, un diff permet de se repérer sur ce que l'on a changé. N'hésitez pas à l'ouvrir si vous êtes paumés.

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);
            }
        }
    }
}

Cela fonctionne en se limitant à de l'ASCII, mais juste écrire le mot "écrire" nous fait sortir de l'ASCII.

Le problème c'est que le "é" quand on l'encode en UTF8 cela devient 2 bytes [0xc3, 0xa9].

Et ça ce n'est plus compatible avec notre magnifique algorithme. 😑

Il va falloir être plus malin.

Et c'est là que la prédiction va être intéressante.

Comme il y a pas de choses à faire, on va décomposer le travail.

La première question que l'on doit se poser, c'est : où est-ce-que l'on peut avoir de l'UTF-8 dans notre parse ?

Réponse, tout ce qui est une chaîne de caractères et pas un mot-clef.

Comme tout cela est très disséminé dans le code, nous allons y aller par étapes.

Commencer par le plus bas et remonter au commandes elle-même.

Echapper des caractères

Cela peut sembler tout bête, mais reconnaître ne serait-ce que 'l\'éléphant', n'est pas si évident.

Si on prédit le ', le goupe va se retrouver amputé d'une bonne partie : l\.

Il faut être plus malin, et concevoir une machine à état qui fonctionne comme suit:

  • consommer les bytes
  • si le byte est ', alors vérifier s'il n'est pas précédé par un \
  • si c'est le cas continuer sa route jusqu'au prochain et recommencer l'algo jusqu'à consommation complète des bytes
  • sinon terminer la prédiction ici.

En voici une implémentation dans src/parser/components/group.rs

fn match_for_delimited_group<'a>(
    token: Token,
) -> impl Fn(&[u8]) -> parser::Result<ForecastResult<Token>> + 'a {
    move |input| {
        // le groupe doit au moins faire 2 tokens de taille
        if input.len() < token.size() * 2 {
            return Ok(ForecastResult::NotFound);
        }

        // on créé un scanner à partir des données
        let mut tokenizer = Tokenizer::new(input);

        // le groupe doit obligatoirement débuter par le token
        if token.recognize(&mut tokenizer)?.is_none() {
            return Ok(ForecastResult::NotFound);
        }
        // on avance de la taille du token reconnu
        tokenizer.bump_by(token.size());

        // ce flag permet de savoir si la prédiction a été un succès
        let mut found = false;

        // tant que la slice contient des bytes, on essaie de reconnaître le token
        while !tokenizer.remaining().is_empty() {
            // si le token est reconnu quelque part dans la slice
            if token.recognize(&mut tokenizer)?.is_some() {
                // on créé un nouveau scanner qui est un token et un \ en arrière
                let mut rewind_tokenizer = Tokenizer::new(
                    &tokenizer.data()
                        [tokenizer.cursor() - token.size() - Token::Backslash.size()..],
                );
                // on tente de reconnaître le \
                if Token::Backslash.recognize(&mut rewind_tokenizer)?.is_some() {
                    // s'il est présent, le token est échappé
                    continue;
                }
                // sinon on a atteint la fin du groupe et la prédiction est un succès
                found = true;
                break;
            }
            // sinon on avance d'un byte
            tokenizer.bump_by(1);
        }

        // Si la prédiction est un échec
        if !found {
            return Ok(ForecastResult::NotFound);
        }

        Ok(ForecastResult::Found {
            end_slice: tokenizer.cursor(),
            start: token,
            end: token,
        })
    }
}

Il est alors possible de récupérer toutes les chaînes délimitées que l'on désire.

#[test]
fn test_match_quotes() {
    let data = b"'hello world' data";
    let result = match_for_delimited_group(Token::Quote)(data).expect("failed to parse");
    assert_eq!(
        result,
        ForecastResult::Found {
            end_slice: 13,
            start: Token::Quote,
            end: Token::Quote
        }
    );
    assert_eq!(&data[..13], b"'hello world'");

    let data = r#"'hello world l\'éléphant' data"#;
    let result =
        match_for_delimited_group(Token::Quote)(data.as_bytes()).expect("failed to parse");
    assert_eq!(
        result,
        ForecastResult::Found {
            end_slice: 27,
            start: Token::Quote,
            end: Token::Quote
        }
    );
    assert_eq!(&data[..27], r#"'hello world l\'éléphant'"#);

    let data = "\"hello world\" data";
    let result = match_for_delimited_group(Token::DoubleQuote)(data.as_bytes())
        .expect("failed to parse");
    assert_eq!(
        result,
        ForecastResult::Found {
            end_slice: 13,
            start: Token::DoubleQuote,
            end: Token::DoubleQuote
        }
    );
    assert_eq!(&data[..13], "\"hello world\"");

    let data = r#""hello world" data"#;
    let result = match_for_delimited_group(Token::DoubleQuote)(data.as_bytes())
        .expect("failed to parse");
    assert_eq!(
        result,
        ForecastResult::Found {
            end_slice: 13,
            start: Token::DoubleQuote,
            end: Token::DoubleQuote
        }
    );
    assert_eq!(&data[..13], r#""hello world""#);
}

Pour rendre tout cela plus facile à utiliser, nous allons rendre un groupe délimité prédictible.

On rajoute deux nouvelles variantes à notre GroupKind.

enum GroupKind {
    Parenthesis,
    Quotes,
    DoubleQuotes,
}

Et on en défini le matcher pour les groupes délimités par des doubles ou des simples guillemets.

impl GroupKind {
    fn matcher<'a>(&self) -> Box<dyn Fn(&'a [u8]) -> parser::Result<ForecastResult<Token>>> {
        match self {
            GroupKind::Parenthesis => Box::new(match_group(Token::OpenParen, Token::CloseParen)),
            GroupKind::Quotes => Box::new(match_for_delimited_group(Token::Quote)),
            GroupKind::DoubleQuotes => Box::new(match_for_delimited_group(Token::DoubleQuote)),
        }
    }
}

Fini pour ça. 😇

Expression

Avant d'essayer de la rendre compatible UTF-8, c'est quoi une Expression ?

Une expression est un ensemble de ColumnExpression séparé par des LogicalOperator.

Les LogicalOperator sont au nombre de deux:

  • AND
  • OR

La ColumnExpression est un groupe composé d'une Column séparé d'une Value par un BinaryOperator.

Le BinaryOperator est entouré d'espaces. Par exemple = ou !=.

La Value a deux variantes:

  • IntegerValue
  • TextValue

Seul le TextValue va nous intéresser.

Le TextValue existe en deux formes:

  • "data" : double guillemets
  • 'data' : simple guillemet

Si on décompose cela donne l'enchaînement suivant:

Column BinaryOperator TextValue LogicalOperator Column BinaryOperator TextValue

Cet enchaînement se nomme une LogicalExpression.

Pour être valide une LogicalExpression doit avoir une ColumnExpression qui se termine forcément par un LogicalOperator, en d'autres terme, soit AND soit OR.

La seul contrainte est donc d'avoir réellement reconnu une ColumnExpression.

TextValue

Commençons par implémenter le TextValue en mode UTF-8.

On prédit le groupe pour être soit du simple soit du guillemets.

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 capture la valeur utf8 qui est encadré entre deux quotes
        let forecast = Forecaster::new(scanner)
            .add_forecastable(GroupKind::Quotes)
            .add_forecastable(GroupKind::DoubleQuotes)
            .forecast()?
            .ok_or(ParseError::UnexpectedToken)?;
        // if faut retirer les quotes de la slice
        let forecast_bytes =
            &forecast.data[forecast.start.size()..forecast.data.len() - forecast.end.size()];
        // on décode depuis l'UTF-8
        let literal_string = String::from_utf8(forecast_bytes.to_vec())
            .map_err(ParseError::Utf8Error)?
            // les caractères d'échappements sont mal géré par les Strings Rust ce qui provoque des
            // affichage peu lisible, on remplace les rajouts surnuméraires
            .replace("\\\"", "\"");

        // on se déplace du groupe prédit
        scanner.bump_by(forecast.data.len());

        // on nettoie les potentiels blancs
        scanner.visit::<OptionalWhitespaces>()?;

        Ok(TextValue(literal_string))
    }
}

A nous les joies des TextValue contenant du JSON avec des apostrophes 😛

#[test]
fn test_text_value_with_json() {
    let data = r#""{\"clé_avec_accent\": \"valeur avec des accents, comme éléphant\", \"texte_avec_apostrophe\": \"C'est une valeur avec une apostrophe.\", \"phrase_avec_espaces\": \"Voici une phrase contenant des espaces.\"}""#;
    let mut tokenizer = Tokenizer::new(data.as_bytes());
    let value = TextValue::accept(&mut tokenizer).expect("failed to parse");
    let expected = r#"{"clé_avec_accent": "valeur avec des accents, comme éléphant", "texte_avec_apostrophe": "C'est une valeur avec une apostrophe.", "phrase_avec_espaces": "Voici une phrase contenant des espaces."}"#;
    assert_eq!(value, TextValue(expected.to_string()));
}

Column

Je spoil un peu mais la Column a 3 usages

  • nom de colonne dans le Select
  • nom de colonne dans le Insert
  • nom de colonne dans une expression

Dans les 2 premiers cas il faut distinguer deux variantes:

  • nom en milieu de groupe
  • nom en fin de groupe

Un groupe est entouré de parenthèses et les Column sont séparé par des virgules. Mais les parenthèses ne sont pas prise en compte.

col1, col2   , col3
  • La col1 est terminé par une virgule
  • La col2 par un espace
  • La col3 par la fin de la slice

On peut alors s'implémenter le comportement dans src/parser/components/columns.rs.

// on reconnait la literal string qui défini une colonne
// plusieurs cas de figure existent:
let name_tokens = Forecaster::new(scanner)
    // soit la colonne est la dernière d'un group parenthésé
    // dont on a retiré la parenthèse
    .add_forecastable(UntilEnd)
    // soit elle est au milieu avec un espace avant la virgule
    .add_forecastable(UntilToken(Token::Whitespace))
    // soit collé à la virgule
    .add_forecastable(UntilToken(Token::Comma))
    .forecast()?
    .ok_or(ParseError::UnexpectedToken)?;

let name = String::from_utf8(name_tokens.data.to_vec()).map_err(ParseError::Utf8Error)?;
scanner.bump_by(name_tokens.data.len());

LogicalExpression

Finalement on peut alors implémenter la détection compatible UTF-8 d'une LogicalExpression et par extension, d'une Expression tout court.

impl<'a> Visitable<'a, u8> for LogicalExpression {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        // on forecast la fin d'un groupe d'expression car si l'on tente
        // de visiter une Expression sans avoir au préalable délimité
        // le contenu du scanner, nous allons éternellement
        // parser le même morceau de LogicalExpression
        // ce qui fait exploser la stack !
        // Tout lhs fini nécessairement par AND ou OR

        // on reconnait la ColumnExpression
        let lhs = scanner
            .visit::<ColumnExpression>()
            .map(Expression::Column)?;

        // si on arrive en fin de slice alors ce n'est pas une LogicalExpression
        if scanner.remaining().is_empty() {
            return Err(ParseError::UnexpectedToken);
        }

        // on nettoie d'éventuels blancs
        scanner.visit::<OptionalWhitespaces>()?;

        // on reconnait l'opérateur logique
        let operator = Recognizer::new(scanner)
            .try_or(Token::Operator(Operator::And))?
            .try_or(Token::Operator(Operator::Or))?
            .finish()
            .ok_or(ParseError::UnexpectedToken)?
            .element
            .try_into()?;

        // on reconnaît au moins un blanc après l'opérateur logique
        scanner.visit::<Whitespaces>()?;

        // on visite l'expression suivante
        let rhs = scanner.visit()?;
        Ok(LogicalExpression::new(lhs, operator, rhs))
    }
}

Le système est robuste au pire atrocités.

Comme avoir des bout d'opérateur logique soit dans les noms de colonnes soit dans les valeurs.

#[test]
fn test_logical_expression_or_and_with_column_name_including_and_token_2() {
    let data = b"id <= 12 OR or != 'and' AND nand = '0101101010001010111'";
    let mut scanner = Scanner::new(data);
    let result = scanner.visit();

    let c1 = ColumnExpression::new(
        Column("id".to_string()),
        BinaryOperator::LessThanOrEqual,
        Value::Integer(12),
    );

    let c2 = ColumnExpression::new(
        Column("or".to_string()),
        BinaryOperator::Different,
        Value::Text("and".to_string()),
    );

    let c3 = ColumnExpression::new(
        Column("nand".to_string()),
        BinaryOperator::Equal,
        Value::Text("0101101010001010111".to_string()),
    );

    let l1 = LogicalExpression::new(
        Expression::Column(c2),
        LogicalOperator::And,
        Expression::Column(c3),
    );

    let l2 = LogicalExpression::new(
        Expression::Column(c1),
        LogicalOperator::Or,
        Expression::Logical(l1),
    );

    assert_eq!(result, Ok(Expression::Logical(l2)))
}

Commandes

Maintenant que nous avons tous les ingrédients, la suite va couler de source.

Create table

La commande CREATE TABLE peut contenir de l'UTF-8 à 2 endroits:

  • nom de la table
  • nom des colonnes

Gérons le nom de la table en premier.

La création de la table vient en deux variation.

CREATE TABLE ma_table(...)

ou

CREATE TABLE ma_table (...)

La différence entre les deux est l'espace entre le nom de la table et la parenthèse ouvrante. Et l'on peut avoir autant d'espaces que l'on désire.

Et c'est là que notre prédiction devient intéressant.

Prenons les requêtes suivantes.

CREATE TABLE éléphants(...)
CREATE TABLE éléphants  (...)

Après que les tokens CREATE TABLE et les espaces consommés, nous nous retrouvons avec les chaînes suivantes

éléphants(...)
éléphants  (...)

Notre but est de capturer le groupe "éléphants", sans les espaces blancs dans le deuxième cas.

Et ça tombe bien, nous avons l'outil parfait pour ça, le Forecaster.

Dans src/parser/commands/create_table.rs.

let table_name_tokens = Forecaster::new(scanner)
    .add_forecastable(UntilToken(Token::Whitespace))
    .add_forecastable(UntilToken(Token::OpenParen))
    .forecast()?
    .ok_or(ParseError::UnexpectedToken)?;
// le groupe prédit est décodé depuis l'UTF-8
let table_name = String::from_utf8(table_name_tokens.data.to_vec()).map_err(ParseError::Utf8Error)?;
// ne pas oublier de faire avancer le scanner du nombre de bytes prédit pour être le nom de la table.
scanner.bump_by(table_name_tokens.data.len());

Et voilà, notre nom de table peut contenir n'importe à part des espaces et des parenthèses ouvrantes.

A vous les joies des noms de tables en mandarin! 😎

Les noms de colonnes dans la définitions du schéma sont moins contrariant: ils finissent obligatoirement par un espace.

Dans src/parser/components/schema.rs.

// on reconnaît une chaîne de caractères représentant un identifiant, il se termine obligatoirement
// par un blanc
let name_tokens = forecast(UntilToken(Token::Whitespace), scanner)?;
// pour décoder le nom du champ
let name = String::from_utf8(name_tokens.data.to_vec()).map_err(ParseError::Utf8Error)?;
scanner.bump_by(name_tokens.data.len());

On profite d'être ici pour corriger la prédiction du groupe de contraintes.

let maybe_constraints = Forecaster::new(scanner)
    .add_forecastable(UntilToken(Token::Comma))
    .add_forecastable(UntilEnd)
    .forecast()?;

Et voilà, la création de la table gère tous les identifiants utilisateurs en UTF-8.

Select

L'UTF-8 va concerner la commande SELECT de trois manières:

  • sur le nom de la table
  • sur la projection
  • dans l'expression de la Where clause
SELECT (col1, col2  , col3) FROM table1;
SELECT (col1, col2  , col3) FROM table1 ;
SELECT (col1, col2  , col3) FROM table1 WHERE col1 = "super valeur" AND col2 = "méga valeur";

Si on décompose cela donne

SELECT (Column, Column  ,Column) FROM <UTF8>;
SELECT (Column, Column  ,Column) FROM <UTF8> ;
SELECT (Column, Column  ,Column) FROM <UTF8> WHERE Expression;

Et on a déjà réglé le cas des Column et de Expression.

Il ne reste que le nom de la table. On distingue deux token d'arrêt.

  • un espace
  • un point-virgule
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, celui-ci peut soit se terminer par un blanc
        // alors un nouveau groupe commence un WHERE par exemple
        // soit se terminer par un point-virgule, la fin de la commande
        let table_name_tokens = Forecaster::new(scanner)
            .add_forecastable(UntilToken(Token::Whitespace))
            .add_forecastable(UntilToken(Token::Semicolon))
            .forecast()?
            .ok_or(ParseError::UnexpectedToken)?;
        
        let table_name =
            String::from_utf8(table_name_tokens.data.to_vec()).map_err(ParseError::Utf8Error)?;
        scanner.bump_by(table_name_tokens.data.len());

        // on nettoie les potentiels espaces
        scanner.visit::<OptionalWhitespaces>()?;

        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,
        })
    }
}

Insert into

Finalement la commande INSERT INTO est un mélange entre les 2 précedentes commandes.

On reconnaît un nom de table, des colonnes et des TextValue.

Et tout ça, on sait le faire.

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 soit un blanc soit une parenthèse ouvrante
        let table_name_tokens = Forecaster::new(scanner)
            .add_forecastable(UntilToken(Token::Whitespace))
            .add_forecastable(UntilToken(Token::OpenParen))
            .forecast()?
            .ok_or(ParseError::UnexpectedToken)?;

        let table_name =
            String::from_utf8(table_name_tokens.data.to_vec()).map_err(ParseError::Utf8Error)?;
        scanner.bump_by(table_name_tokens.data.len());

        // 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 })
    }
}

Et voilà ! Toutes les entrées utilisateur sont désormais débarassées de la contrainte de l'ASCII.

On va pouvoir s'amuser un peu.

Testons !

UTF-8

On se créé une table bien franchouillarde, avec des accents.

CREATE TABLE AnnuaireTéléphonique(nom TEXT(50) PRIMARY KEY, prénom TEXT(50), ville TEXT(50), téléphone TEXT(15), genre TEXT(1));

Puis on y insère de l'UTF-8.

INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Dupont', 'Amélie', 'Paris', '+33612345679', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Benkacem', 'Fatima', 'Paris', '+33634567890', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Nguyễn', 'Claire', 'Paris', '+33628345678', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Durand', 'Jean-Pierre', 'Lyon', '+33658466789', 'M');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Traoré', 'Omar', 'Marseille', '+33692345678', 'M');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Martins', 'Sofia', 'Nice', '+33678432109', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Garnier', 'Théo', 'Bordeaux', '+33643215678', 'M');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Diallo', 'Aïcha', 'Strasbourg', '+33654987654', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Morel', 'Camille', 'Toulouse', '+33681234567', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('Lefèvre', 'Victor', 'Lille', '+33612345678', 'M');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('李 (Li Wei)', '', 'Paris', '+33687654321', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('山田 (Yamada Aiko)', '愛子', 'Paris', '+33676543210', 'F');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('陈 (Chen Ming)', '', 'Marseille', '+33698765432', 'M');
INSERT INTO AnnuaireTéléphonique (nom, prénom, ville, téléphone, genre) VALUES ('田中 (Tanaka Hiroshi)', '', 'Lyon', '+33665498732', 'M');

Et Finalement on select.

SELECT (nom, prénom, ville, téléphone, genre) FROM AnnuaireTéléphonique WHERE ville = 'Paris' AND genre = 'F';

Résultat:

[Text("Dupont"), Text("Amélie"), Text("Paris"), Text("+33612345679"), Text("F")]
[Text("Benkacem"), Text("Fatima"), Text("Paris"), Text("+33634567890"), Text("F")]
[Text("Nguyễn"), Text("Claire"), Text("Paris"), Text("+33628345678"), Text("F")]
[Text("李 (Li Wei)"), Text("伟"), Text("Paris"), Text("+33687654321"), Text("F")]
[Text("山田 (Yamada Aiko)"), Text("愛子"), Text("Paris"), Text("+33676543210"), Text("F")]

Bienvenue dans le monde de UTF-8 partout où on peut le caser ^^

JSON

UTF-8 mais pas que, on a aussi la possibilté d'échapper des caractères et donc de stocker du JSON.

CREATE TABLE mongo(mongo_id INTEGER PRIMARY KEY, data TEXT(300));
INSERT INTO mongo(mongo_id, data) VALUES (666, "{\"clé_avec_accent\": \"valeur avec des accents, comme éléphant\", \"texte_avec_apostrophe\": \"C'est une valeur avec une apostrophe.\", \"phrase_avec_espaces\": \"Voici une phrase contenant des espaces.\"}");    
SELECT * FROM mongo;
[Integer(666), Text("{\"clé_avec_accent\": \"valeur avec des accents, comme éléphant\", \"texte_avec_apostrophe\": \"C'est une valeur avec une apostrophe.\", \"phrase_avec_espaces\": \"Voici une phrase contenant des espaces.\"}")]

Et là également tout marche. 😎

Conclusion

Je vous jure ça devait être un article simple, mais je me suis un peu laissé emporter 🤣

On utilisera le fuzzing prochainement pour nous assurer de la correction de notre parser.

Mais pour le moment, c'est fini pour aujourd'hui.

Dans la prochaine partie nous verront comment gérer les données nullable.

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.