Partie 14 : Attributs nullifiables
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 😃
Lorsque nous avons introduit les clef primaires, j'ai défini deux contraintes appliquable sur les colonnes.
- PRIMARY KEY
- NOT NULL
PRIMARY KEY
on s'en est déjà occupé, NOT NULL
par contre, on l'a laissé sur le bas côté.
Il est temps d'y remédier.
Le but va être de pouvoir insérer de la données sans définir toutes les colonnes systématiquement.
INTEGER NOT NULL PRIMARY KEY, name TEXT(50) NOT NULL, age INTEGER);
(id INSERT INTO Users (id, name, age) VALUES (1, 'Max', 20);
INSERT INTO Users (id, name) VALUES (2, 'Amy');
Let's go !
Null Table
La pierre angulaire de notre système va être un concept que l'on nomme en informatique une null table.
Qu'est-ce que NULL
, d'ailleurs ?
NULL matérialise le concept d'absence de données, pas un 0, pas un FALSE. Son absence.
Du coup stocker du vide, ça commence à être tendax, tout est des bytes, des 0 et des 1. On n'a pas de troisième état qui permet de définir le néant.
On va diviser pour mieux régner.
On va définir une table qui dire si oui ou non la données est présente ou pas.
Prenons un exemple avec un tableau d'entiers possédant une valeur NULL.
[1, NULL, 3]
Si on veut sérialiser ça, nous allons devoir faire un choix de représentation du NULL, je vais arbitrairement dire que c'est 0x0.
Ce qui nous donne.
0x1 0x0 0x2
Mais rien ne distingue pour le moment 0x0 d'un vrai 0 ou d'un NULL.
L'idée est de créer une table de vérité de nos données:
- 1 elle existe
- 0 non
Ce qui donne la table suivante:
[1, 0, 1]
Que l'on sérialise en
0x1 0x0 0x1 0x1 0x0 0x2
^ ^
null table data
On peut alors utiliser le 0x0
de la null table pour déterminer si c'est du 0 ou du NULL.
Par contre, on double notre stockage. C'est pas tip-top.
Heureusement, nous n'avons pas exploré toutes les possibilités.
Lorsque l'on écrit 0x5
Ce qu'il faut lire c'est
0b101
Et donc un tableau de 1 et de 0.
On peut compactifier notre null table avec un simple nombre.
0b101 0x1 0x0 0x2
^ ^
null table data
Ok, mais comment à partir de
0x5
je suis sensé savoir que le deuxième élément des données est NULL ?
Et bien grâce à deux opérations binaires extrèmement optimisées par le CPU:
- le masque
- le décalage binaire
Le décalage binaire permet comme son nom l'indique de décaler un bit, il est symbolisé par <<
quand il décale vers la gauche.
0b001 << 1 = 0b010
Ici on décale à gauche notre bit.
Dans les faits c'est une multiplication par 2^n
1 << 1 = 1 * 2 = 0b01 << 1 = 0b10 = 2 1 << 2 = 1 * 2^2 = 0b01 << 2 = 0b100 = 4
Mais contrairement à la puissance qui coûte très cher en CPU, décaler des bits est évident pour le CPU car directement câblé physiquement.
Le masque est également une opération câblée dans le CPU qui permet de vérifier que deux bit sont égaux.
On utilise pour ça une opération de ET représenté par &
0b101
& 0b110
-----------
0b100
On peut alors fusionner les deux opérations.
null_table & (1 << bit)
Si on reprend notre exemple, cela donne:
0b101 // null_table
& 0b010 // 0b001 << 1
-----------
0b000 = 0x0
Par contre si on veut la première valeur
0b101 // null_table
& 0b001 // 0b001 << 0
-----------
0b001 = 0x1
Ou la troisième
0b101 // null_table
& 0b100 // 0b001 << 2
-----------
0b100 = 0x4
Et donc notre algo est:
null_table & (1 << bit) == 0
Compris, mais un byte ne comporte que 8 bits, on fait comment si notre tuple a plus de 8 attributs?
Réponse courte, on utilise 2 nombres au lieu d'un! 😎
Réponse plus élaboré.
Pour ce set de data:
[NULL, 2, 3, NULL, 5, 6, 7, 8, 9, NULL]
On a 3 positions qui sont NULL:
- 0
- 3
- 9
Pour les position 0 et 3 pas de souci, c'est notre null-table classique : 0b01101111
. Par contre la suite on la met où ?
Ben à la suite !
Le deuxième groupe de 8 attributs donne la null-table suivante: 0b01
On fusionne les deux.
Notre null-table complète est donc:
0b0101101111
Mais comme ça ne rentre pas dans notre sérialisation en byte, on scinde en deux.
0b01101111 0b01
^ ^
partition 0 partition 1
On va appeler chacun des groupe une partition.
Et donc si on sérialise
[NULL, 2, 3, NULL, 5, 6, 7, 8, 9, NULL]
Cela devient
0x6f 0x01 0x0 0x2 0x3 0x0 0x5 0x6 0x7 0x8 0x9 0x0
^ ^
null table data
On fera difficilement plus compact. 😅
Et avec ce tableau de 2 nombres, je fais comment pour savoir si l'attribut est null ou pas ?
Et bien, on sait que la partition fera forcément 1 octet, donc 8 bits.
Si on recherche le bit 12, il ne peut pas se retrouver dans la partition 0 qui ne va que jusqu'à l'index 7.
Donc pour trouver la partition, il suffit de diviser cet index par la taille de la partition ici 8.
partition = index / 8
Et pour trouver le bit qui doit être masqué on récupère le reste de la division par le modulo de cette division
bit = index % 8
Finalement il vient
partition = index / 8
bit = index % 8
null_table[partition] & (1 << bit) == 0
Et là on a tout ! 😇
Ajout des Value::Null
Maintenant que nous avons bien théorisé tout cela, nous allons pouvoir nous attaquer à l'implémentation de la fonctionnalité.
Pour cela nous allons rajouter une nouvelle variante à notre énumération Value
.
On en profite pour implémenter le Serializable pour la nouvelle variante.
Modification du check_values
Maintenant que nous avons défini notre Value::Null
, nous allons améliorer notre méthode check_values qui a pour but de vérifier la conformité de la données.
On se rajoute pour l'occasion une nouvelle erreur.
Celle-ci permet d'interdire une valeur d'être NULL si le schéma ne le permet pas.
Implémentation de la null table
Nous devons maintenant déterminer la null table associer au tuple de donnée.
/// Taille de la partition de la null table d'un octet
pub const PARTITION_SIZE: usize = 8;
Ce qui donne ce fonctionnement.
La deuxième chose que l'on a besoin de définir est l'opération de détection de NULL value.
Et qui peut être utilisé de cette manière:
Et nous allons définir une dernière méthode qui à partir du schéma vient définir la taille de la null table de chaque tuple de la page.
Pour cela, on recherche le multiple de 8 supérieur ou égal au nombre d'attributs du tuple.
Cela donne:
Je me permets également de rajouter la possibilité de sérialiser du u8.
Modification de Schema
La première chose à faire est de corriger la taille du tuple dans la page, en effet, il faut maintenant rajouter la null_table en plus.
Ensuite, il faut redéfinir la méthode de sérialisation des données en prenant en compte que certains champs ne seront pas présent.
Il faut alors définir et encoder la null_table.
Puis pour chaque attribut, si la valeur est null, ne rien sérialiser du tout, sinon sérialiser la donnée.
Et l'opération inverse de désérialisation, qui consiste à récupérer la null table, puis pour chaque attribut si la null table indique un NULL, passer la désérialisation et renvoyer un Value::Null
.
Et on peut tester notre merveille !
Testons !
On définit deux champs NOT NULL
- id
- name
Et on laisse nullifiable l'âge.
INTEGER NOT NULL PRIMARY KEY, nom TEXT(50) NOT NULL, âge INTEGER);
(id
On peut alors insérer partiellement de la donnée.
INSERT INTO Users (id, nom, âge) VALUES (1, 'Max', 20);
INSERT INTO Users (id, nom) VALUES (2, 'Amy');
Par contre, si on va trop loin dans le "partiel" et que l'on oublie de remplir le nom
INSERT INTO Users (id) VALUES (3);
Insertion(Serialization(ColumnDefinition(NonNullableColumn { column_name: "nom" })))
On peut alors sélectionner nos données
SELECT * FROM Users;
[Integer(1), Text("Max"), Integer(20)]
[Integer(2), Text("Amy"), Null]
Nous avons bien "Amy" qui est sans âge ^^
Conclusion
Pas du tout non ? On commence à entrevoir du sql, la route est encore longue mais l'on progresse. 😎
Dans la prochaine partie nous verront comment gérer les index secondaires.
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.