Partie 13 : UTF-8 et échappement de caractères
Les articles de la série
- Partie 1 : Construire le REPL
- Partie 2 : Sérialisation des données
- Partie 3 : Database
- Partie 4 : Tables
- Partie 5 : Parser les commandes, création de la boîte à outils
- Partie 6 : Parser les commandes SQL
- Partie 7 : Tuples de données
- Partie 8 : Contraindre les données via un schéma
- Partie 9 : Découper le stockage des tables en pages
- Partie 10 : Clefs primaires
- Partie 11 : Expressions Logiques SQL
- Partie 12 : Scans et filtrage
- Partie 13 : UTF-8 et caractères échappés
- Partie 14 : Attributs nullifiables
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:
new
.try_or?
.try_or?
.finish
.ok_or?;
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:
new
// ajout des prédictions
.add_forecastable
.add_forecastable
// prédictions
.forecast?
.ok_or?;
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.
Le
Box<dyn Forecast<'a, T, S>>
est nécessaire car nous voulons pouvoir accumuler desForecast
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.
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.
Petit test parce que l'on est jamais trop prudent.
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.
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
+ 'a
Il est alors possible de récupérer toutes les chaînes délimitées que l'on désire.
Pour rendre tout cela plus facile à utiliser, nous allons rendre un groupe délimité prédictible.
On rajoute deux nouvelles variantes à notre GroupKind
.
Et on en défini le matcher pour les groupes délimités par des doubles ou des simples guillemets.
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.
A nous les joies des TextValue contenant du JSON avec des apostrophes 😛
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 = new
// soit la colonne est la dernière d'un group parenthésé
// dont on a retiré la parenthèse
.add_forecastable
// soit elle est au milieu avec un espace avant la virgule
.add_forecastable
// soit collé à la virgule
.add_forecastable
.forecast?
.ok_or?;
let name = String from_utf8.map_err?;
scanner.bump_by;
LogicalExpression
Finalement on peut alors implémenter la détection compatible UTF-8 d'une LogicalExpression et par extension, d'une Expression tout court.
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.
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.
(...)
ou
(...)
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 = new
.add_forecastable
.add_forecastable
.forecast?
.ok_or?;
// le groupe prédit est décodé depuis l'UTF-8
let table_name = String from_utf8.map_err?;
// ne pas oublier de faire avancer le scanner du nombre de bytes prédit pour être le nom de la table.
scanner.bump_by;
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?;
// pour décoder le nom du champ
let name = String from_utf8.map_err?;
scanner.bump_by;
On profite d'être ici pour corriger la prédiction du groupe de contraintes.
let maybe_constraints = new
.add_forecastable
.add_forecastable
.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
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.
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.
TEXT(50) PRIMARY KEY, prénom TEXT(50), ville TEXT(50), téléphone TEXT(15), genre TEXT(1));
(nom
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.
INTEGER PRIMARY KEY, data TEXT(300));
(mongo_id
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à.
Ce travail est sous licence CC BY-NC-SA 4.0.