Partie 8 : Contraindre les données via un schéma
Bonjour à toutes et tous 😃
Le système de tuple que l'on a bâti en partie 7 semble marcher, mais on a déjà relevé des problèmes sur le retour des données qui n'est pas constant.
db > CREATE TABLE Users (id INTEGER, name TEXT(50), email TEXT(128));
db > INSERT INTO Users(id, name, email) VALUES (1, 'John Doe', 'john.doe@example.com');
db > INSERT INTO Users(id, name, email) VALUES (2, 'Jane Doe', 'jane.doe@example.com');
db > SELECT * FROM Users;
[Text("John Doe"), Text("john.doe@example.com"), Integer(1)]
[Text("jane.doe@example.com"), Integer(2), Text("Jane Doe")]
Mais il y a bien pire que des champs mal ordonnés. On peut carrément rajouter de nouveau champs non-attendus.
db > INSERT INTO Users(id, name, email, phone) VALUES (3, 'Jack Doe', 'jane.doe@example.com', '+33455325.52');
db > SELECT * FROM Users;
[Integer(3), Text("jane.doe@example.com"), Text("Jack Doe"), Text("+33455325.52")]
Ou mal typés !
db > INSERT INTO Users(id, name, email) VALUES ('1', 'John Doe', 'john.doe@example.com');
db > SELECT * FROM Users;
[Text("John Doe"), Text("john.doe@example.com"), Text("1")]
Bref, c'est une catastrophe !! 😱
Tout ça parce que le schéma de la commande de création de la table est juste la pour la déco. On n'en fait rien du tout!
db > CREATE TABLE Users (id INTEGER, name TEXT(50), email TEXT(128));
| |
--------------------------------------------
schéma
Pour remédier à tout cela, nous allons devoir contraindre nos insertions. 😎
Contraindre les tuples
Nos tuples nous ont libéré de tout usage d'entité fixée, mais la contrepartie, c'est que nous avons perdu beaucoup de garanties sur nos données.
Garanties que nous allons graduellement retrouver.
Ordre des champs
Notre structure de schéma actuelle a un problème, elle ne conserve pas l'ordre d'insertion car c'est une HashMap
.
Et lors de l'insertion des données qui sont aussi une HashMap<String, Value>
, on se content de collecter les valeurs de cette map sous la forme d'un tuple.
let values = data.into_values.;
table.insert?;
Le problème c'est que l'ordre est défini par des règles que l'on ne maîtrise pas totalement.
Ce qui provoque les atrocités vu en introduction.
db > CREATE TABLE Users (id INTEGER, name TEXT(50), email TEXT(128));
db > INSERT INTO Users(id, name, email) VALUES (1, 'John Doe', 'john.doe@example.com');
db > INSERT INTO Users(id, name, email) VALUES (2, 'Jane Doe', 'jane.doe@example.com');
db > SELECT * FROM Users;
[Text("John Doe"), Text("john.doe@example.com"), Integer(1)]
[Text("jane.doe@example.com"), Integer(2), Text("Jane Doe")]
La solution est alors de venir adjoindre une liste qui elle conserve l'ordre quelque soit la valeur de nos données.
Ainsi on peut alors faire quelque chose dans ce genre :
Comme le tuples est maintenant contraint en ordre de sérialisation, si on désérialise, on obtiendra
db > CREATE TABLE Users (id INTEGER, name TEXT(50), email TEXT(128));
db > INSERT INTO Users(id, name, email) VALUES (1, 'John Doe', 'john.doe@example.com');
db > INSERT INTO Users(id, name, email) VALUES (2, 'Jane Doe', 'jane.doe@example.com');
db > SELECT * FROM Users;
[Integer(1), Text("John Doe"), Text("john.doe@example.com")]
[Integer(2), Text("Jane Doe"), Text("jane.doe@example.com")]
Car la désérialisation conserve l'ordre. Les champs ne sont plus sans-dessus-dessous, nous avons réglé notre premier problème. 😎
Respecter le nombres de champs
Nous allons maintenant nous attaquer au second problème qui est de pouvoir rajouter des champs supplémentaire ou inconnu.
db > INSERT INTO Users(id, name, email, phone) VALUES (3, 'Jack Doe', 'jane.doe@example.com', '+33455325.52');
db > SELECT * FROM Users;
[Integer(3), Text("jane.doe@example.com"), Text("Jack Doe"), Text("+33455325.52")]
Pour cela nous allons donner à notre schéma le pouvoir judiciaire de valider ou non les tuples de données.
Nous allons matérialiser le verdict sous la forme d'une nouvelle erreur CheckColumnDefinitionError
.
On peut alors définir la méthodes check_values
.
Cela résout nos deux problèmes d'un coup
Conformité de type de données
Notre troisième problème est la non cohérence des types de données.
db > INSERT INTO Users(id, name, email) VALUES ('1', 'John Doe', 'john.doe@example.com');
db > SELECT * FROM Users;
[Text("1"), Text("John Doe"), Text("john.doe@example.com")]
On va en profiter pour contraindre la taille des chaîne de caractères insérables.
Dans la même méthode on rajouter une collection vérification.
Notre schéma peut désormais invalider les tuples incorrects.
Avant insertion, il alors possible de demander au schéma d'interdire les données incorrectes.
// Vérification des champs
table.schema.check_values.map_err?;
let mut fields = vec!;
// Réorganisation du tuples
for column in table.schema.columns.iter
// insertion
table.insert?;
On se rapproche du bout! 😍
Il nous reste la dernière contrainte de taille fixe de tuple. Et nous allons le voir tout de suite.
Sérialialisation a taille fixe
Nouvelle sérialisation
Dans la partie 7, je vous avais avertis que la sérialisation actuelle était temporaire.
Pour un tuple [Integer(42), Text("test")]
cela donne.
0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xd6 0x01 0x04 0x74 0x65 0x73 0x74
^ ^ ^ ^ ^ ^
taille D=Int 42 sur 8 bytes encodé en litte-endian D=Text taille "test" encodé en UTF-8
du Vec String
Ce modèle de sérialisation est appelé "auto-porteur", car sans avoir de schéma on peut désérialiser la donnée en utilisant les différents maqueurs disséminé
- taille du tuple
- discriminant de la variante
- taille de la chaîne de caractères
Ce qui est cool parce que l'on peut faire ce que l'on désire de données et stocker virtuellement n'importe quoi, pensez Mongo DB.
Mais pas glop dans une base de données relationnelle comme sqlite qui se base massivement sur des schémas pour opérer efficacement, se retrouver avec des données disparatres est un vrai problème.
Problème qui est comblé par l'utilisation d'un associé à une table.
Par exemple id INTEGER, name TEXT(50)
, un entier suivi d'une chaîne de maximum 50 bytes.
Nous obtenons alors deux informations cruciales de ce schéma:
- il y a deux champs
- le premier est un entier, le second une chaîne de caractères
Une règle arbitraire que nous allons nous fixer est que toutes les entrées font toutes la même taille. Cette règle va être très utile pour la suite de nos développements.
Cela veut dire que le mot "test" ou "anticonstitutionnellement" respectivement de 4 et 26 caractères vont être effectivement encodé dans 50 bytes.
Pour cela, il y a deux méthodes:
- soit l'on réserve un bytes pour la taille comme précédemment
- soit on utilise le bon vieux caractère NULL
0x00
comme caractère de fin de string
Cette série d'article n'étant qu'un gros prétexte pour essayer des choses, je opter pour la deuxième solution. 😇
Finalement, notre tuple encodé depuis le schéma nous donne:
NULL signifiant la fin de la chaîne
ˇ
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xd6 0x74 0x65 0x73 0x74 0x00 0x00 0x00 ... 0x00 0x00 0x00
^ ^ ^
42 sur 8 bytes encodé en litte-endian "test" encodé en UTF-8 50ème byte
Chaque entrée prend alors 58 bytes !! Quelque soit le contenu réel.
On verra dans le futur comment optimiser cette taille gargantuesque de tuple encodé.
Modification des traits serde
Nous allons opérer une petite modification du trait Serializable
en lui permettant de renvoyer la taille encodée.
Cela va être cruciale par la suite.
Modification du serde des primitives
Les entiers ne bougent pas car ils sont déjà optimalement stockés. Par contre on renvoie tout de même la taille encodée.
Tout nos entiers sont des
i64
donc sur 8 bytes, on pourra dans l'avenir définir des types plus restreints comme duu8
par exemple.
Notre String va subir une petite modification à la fois en sérialisation et en désérialisation.
Lors de la sérialisation, nous n'encodons plus la taille de la string avant la data. A la place, on encode la string puis on lui rajoute le caractère NULL.
Pour la désérialisation, nous pourrions nous contenter d'utiliser le read_until. Mais je ne suis pas fan de la double allocation de buffer que cela nous contraint. La première pour lire les données, la seconde pour l'encodage UTF-8.
A la place nous allons être un peu malin et lire la données sans la copier, trouver ce qui nous intéresse et puis encoder la slice, ainsi pas de copie des bytes dans deux buffer.
Sécurité
Ce mode lecture doit-être utilisé seulement sur des données que vous connaissez, ici c'est un modèle jouet, mais dans la réalité une erreur d'encodage qui n'écrit jamais le NULL et vous êtes soumis à une erreur de type buffer overflow car les bytes renvoyés seront au-delà de la données attendue !
Les bytes suivants peuvent avoir accueilli n'importe quoi, il peut rester des traces de données précédentes !
Notre nouvelle sérialisation est prête !
Vous noterez que dans ce mode de sérialisation, les données sont à la queue leu-leu et il n'y a pas de constances dans la longeur des champs string.
Nous allons utilisé notre schéma comme guide sérialisation.
Reposer le formatage du tuple sur le schéma
Notre schéma est constitué d'une HashMap<String, ColumnType>
. C'est par le biais de ColumnType
qui je le rappele est une énumération.
Que l'on va pouvoir opérer à notre mise en place d'une sérialisation a taille fixe.
Mais avant il va falloir simplifier la sérialisation de Value
.
Auparavant, nous encodions le un discriminant de type de variante.
Mais comme désormais le schéma gère le type et l'ordonnencement des champs, il devient inutile d'encoder ce discriminant.
On retire également l'implémentation de Deserializable
car il n'est plus possible de désérialiser sans schéma l'énumération. Et je ne veux pas modifier sa signature.
Du coup je vais plutôt passer par le ColumnType
pour réaliser la sérialisation.
Maintenant que la sérialisation n'encode que les données et plus le type de celles-ci. Le schéma devient crucial pour relire nos données.
Sérialiser au travers du schéma
On peut alors déplacer la logique de vérification et de sérialisation des données sous la responsabilité du Schema
.
On applique de même pour la désérialisation.
Déplacement de la logique d'insertion au niveau de la table
Le schéma étant présent au niveau de la Table, il est plus naturelle d'y placer la logique.
La Table qui précédemment permettait n'importe quelle données Serializable
pouvait être insérées.
Désormais, nous n'accepterons plus que des HashMap<String, Value>
seuls types consommable par la sérialisation par schéma.
On peut faire de même pour la sélection.
Modification de la Database
On peut alors enlever la boucle qui ordonnençait les champs lors de l'insertion.
Désormais nous n'avons plus besoin de réorganiser les champs dans les tests
Testons tout ça!
D'abord on vérifie la cohérence d'ordre des champs par rapport au schéma
db > CREATE TABLE Users (id INTEGER, name TEXT(20), email TEXT(50));
db > INSERT INTO Users (id, name, email) VALUES (42, 'john.doe', 'john.doe@example.com');
db > INSERT INTO Users (id, name, email) VALUES (666, 'jane.doe', 'jane.doe@example.com');
db > SELECT * FROM Users;
[Integer(42), Text("john.doe"), Text("john.doe@example.com")]
[Integer(666), Text("jane.doe"), Text("jane.doe@example.com")]
Check ✅
On peut aussi vérifier la contrainte sur les tailles de chaînes de caractères.
db > INSERT INTO Users (id, name, email) VALUES (1, 'Un nom beacoup trop long enfin plus que 20 caracteres', 'email@example.com');
Insertion(Serialization(ColumnDefinition(ExceededMaxSize { max_size: 20, got: 53, column_name: "name" })))
Check ✅
L'incohérence de type de colonne par rapport au schéma.
db > INSERT INTO Users (id, name, email) VALUES ('666', 'jane.doe', 'jane.doe@example.com');
Insertion(Serialization(ColumnDefinition(WrongType { expected: Integer, got: Text(3), column_name: "id" })))
Check ✅
Des colonnes inconnues.
db > INSERT INTO Users (id, unknown, email) VALUES (666, 'jane.doe', 'jane.doe@example.com');
Insertion(Serialization(ColumnDefinition(UnknownColumn("unknown"))))
Check ✅
Check ✅, check ✅, check ✅ !!
Tout est fonctionnel !! 😍😍😍
Je ne l'ai fait que pour une table, mais bien évidemment chaque table possède son schéma de contraintes respectif.
Conclusion
Phieeeew!!!!
On a bien bossé là ! Nous avons maintenant des données contraintes et normalisées.
Cette normalisation va nous permettre d'attaquer la partie suivante qui parlera des curseurs et des pages.
Une notion qui va être centrale dans notre système de stockage.
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.