Partie 10 : Clefs primaires
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
- Partie 15 : Index secondaires
Bonjour à toutes et tous 😃
Pour le moment notre seule façon de récupérer de la donnée depuis notre table consiste à réaliser un full-scan, ce qui est veut dire partir du premier byte de la table et désésérialiser jusqu'à la fin.
Autant dire que ce n'est pas vraiment optimale si on cherche un tuple en milieu de table. 😅
Tout comme à l'époque du téléphone à fil, il y avait un gros bouquin qui référençait le nom de la personne par rapport à son numéro. Nous allons faire de même avec nos tuples.
La question maintenant est de savoir dans notre cas qui est le "nom" et qu'est ce que le "numéro" de notre "annuaire" à nous.
Il va nous falloir plus d'outils pour répondre à ces questions.
Modifiation du parser
La première chose que l'on va rajouter c'est des nouvelles capacité à notre parser.
Nouvelle grammaire
Pour pouvoir définir qui sera le "nom" de notre annuaire, nous allons définir un flag sur un des champs du schéma de la table à la création de celle-ci.
Ce flag va se nommer PRIMARY KEY
, un champ disposant de cette contrainte se nomme la "clef primaire" de la table, autrement dit la manière la plus simple de retrouver de la données dans une table.
Voici la nouvelle grammaire du parser pour la commande CREATE TABLE
.CREATE TABLE avec PRIMARY KEY
CreateTable ::= 'CREATE' 'TABLE' LiteralString OpenParen ColumnDef* CloseParen Semicolon
ColumnDef ::= LiteralString ColumnType | LiteralString ColumnType Comma
ColumnType ::= 'INTEGER' Constraint* | ColumTypeText Constraint*
ColumTypeText ::= 'TEXT' OpenParen LiteralInteger CloseParen
LiteralString ::= *
LiteralInteger ::= +
Constraint ::= 'PRIMARY' 'KEY' | 'NOT' 'NULL'
OpenParen ::= '('
CloseParen ::= ')'
Comma ::= ','
Semicolon ::= ';'
Les colonnes disposent désormais d'un nouveau composant Constraint
qui va être soit l'enchaînement des tokens PRIMARY KEY
soit NOT NULL
.
Il est possible de d'avoir à la fois PRIMARY KEY
et NOT NULL
sur une même colonne.
La deuxième modification va être sur la commande SELECT
.
Pour le moment, on réalise uniquement ce que l'on nomme des "full-scan", c'est à dire que l'on part de l'index 0 de nos tuples, démarrant à la page 0 de notre table, et on itère jusqu'à ne plus avoir d'entrée à retourner.
Cela est du à la pauvreté de notre commande SELECT
qui ne permet pas de nommer ce que l'on désire. Il nous manque littéralement des "mots" pour nous exprimer! 😅
Nous allons les rajouter avec cette nouvelle grammaire:SELECT FROM avec clause WHERE
Select ::= 'SELECT' Column* 'FROM' LiteralString WhereClause Semicolon
Column ::= '*' | LiteralString | LiteralString Comma
WhereCaluse ::= 'WHERE' Identifier '=' LiteralValue
LiteralString ::= "'" * "'"
Identifier ::= *
LiteralInteger ::= +
LiteralValue ::= LiteralInteger | LiteralString
OpenParen ::= '('
CloseParen ::= ')'
Comma ::= ','
Semicolon ::= ';'
Elle introduit le nouveau mot-clef WHERE
qui permet d'écrire une expression du type champ = value
, ce qui signifie : "renvoie moi le champ qui possède cette valeur".
Ajouts des tokens
Nous allons rajouter 6 tokens supplémentaires:
- PRIMARY
- KEY
- NOT
- NULL
- WHERE
- =
J'en profite pour définir un token technique qui permet d'utiliser l'API Token sans devoir définir quelque chose de précis à reconnaître.
On rajoute les matchers qui vont bien:
En n'oubliant pas de rajouter les implémentation de Size
pour ces nouveaux tokens
Forecaster UntilEnd
Son travail est simple, reconnaître la fin de la slice.
;
On consomme jusqu'à la fin, son existence peut sembler absurde, mais il va permettre de construire des API d'alternatives de reconnaissances de pattern bien plus naturellement.
Définition des contraintes
Pour parser nos contraintes PRIMARY KEY
et NOT NUL
, il faut pouvoir les reconnaître.
Pour se faire on rajoute deux visiteurs:
PrimaryKeyConstraint
qui reconnaît l'enchaînement de tokens PRIMARY
suivi d'un nombre supérieur à 1 d'espace puis le token KEY
.
;
On fait de même avec le NotNullConstraint
;
On fusionne les visiteurs dans une énumération
Et son pendant Constraint
associé à sa glu technique.
On peut alors en faire un Visteur.
Ajout des contraintes sur la définition de la colonne
On rajoute la possibilité d'avoir des contraintes sur une colonne.
Un groupe de contraintes est séparé par un espace blanc.
Lorsque l'on défini des containtes, on peut se retrouver dans deux cas:
- soit c'est un groupe de containtes sur un champ du schéma qui n'est pas le dernier champ du schéma : ce champ se termine par une virgule
,
- soit le groupe de contraintes est sur le dernier champ du schéma : ce champ se termine par rien du tout
Modification du visiteur Schema
Ce qui donne:
Nous sommes désormais capables de parser les contraintes de la tables ! 🤩
Clef primaire du schéma
Une des contraintes qui le plus important pour nous à l'instant présent est PRIMARY KEY
, celle ci dispose plusieurs règle de définition.
- au moins un des champs doit-être une clef primaire
- il ne peut pas y avoir plus d'un champ comme clef primaire d'une table
Nous allons matérialiser cela à la fois via des erreurs
et une méthode qui a pour objet de trouver la contrainte unique de clef primaire et renvoyer dans les autres cas.
La primary key est un
Vec<String>
car il est possible d'associer plus d'un champ pour construire une clef primaire.On nomme ça des clefs composites, si vous voulez prendre de l'avance sur la suite. 😇
On défini un nouveau champ primary_key
sur notre Schema
.
Lors du parse on va alors appeler notre méthode parse_primary_key
depuis notre visiteur de Schéma.
Ce qui permet de parser nos schémas
Nous avons désormais la certitude d'avoir une clef primaire unique dans la définition du schéma de notre table.
On avance ! 🤩
Clause Where
Notre clause est extrêmement simplifié par rapport à la réalité de SQL (chaque chose en son temps 😅).
On associe un champ et une valeur séparé par un token =
.
Notre association se matérialise par la structure
Que l'on visite
Ce qui donne
Modification de la commande SELECT
On rajoute alors notre clause where qui peut optionnellement exister
Que l'on peut alors visiter
Donnant:
Indexation des entrées
Maintenant que notre parser est amélioré pour définir la clef primaire de la table, nous allons pouvoir l'utiliser pour remplir notre "annuaire".
Et voici la réponse à la question "Qui est le nom et le numéro de notre annuaire ?".
Notre "nom" sera un tableau de valeur, un tuple en fait
Et notre "numéro" sera l'ID du tuple dans la table.
type PrimaryIndexes = ;
Notre "annuaire" sera stocké au niveau de la table
Lors de l'insertion, nous allons récupérer les valeurs des champs qui constituent la clef primaire de notre table.
Si la clef primaire est impossible à trouver, ceci est une erreur, même si dans les faits, cette erreur ne peut pas exister car le schéma vérifie préemptivement le tuple à insérer.
Il est alors possible avant insertion dans la page de venir rajouter notre clef primaire dans l'index primaire.
Query Engine
Pour simplifier les recherches et les scans de tables et surtout pour éviter que le fichier tables n'enfle démesurément, nous allons mettre la logique de recherche au sein d'un QueryEngine
.
Celui-ci prend une référence de Table
en paramètre.
On peut alors créer des comportement de recherche différent.
D'abord le full-scan, qui est déplacement du code actuelle de la table vers le QueryEngine
Puis notre recherche par index primaire.
Pour cela nous avons besoin de d'un utilitaire get_row
qui récupère un tuple par rapport à son ID.
Et une méthode get_by_pk
, le pk
signifie "Primary Key".
Utilisation du Query Engine
Tout étant correctement découpé, il est possible de segmenter nos recherches entre le full scan et la recherche par PK.
Testons !!
On se créé une table qui possède une clef primaire "id".
db > (id INTEGER PRIMARY KEY, 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 > INSERT INTO Users (id, name, email) VALUES (1, 'admin', 'admin@example.com');
On full scan la table
db > SELECT * FROM Users;
[Integer(42), Text("john.doe"), Text("john.doe@example.com")]
[Integer(666), Text("jane.doe"), Text("jane.doe@example.com")]
[Integer(1), Text("admin"), Text("admin@example.com")]
On récupère par PK.
db > SELECT * FROM Users WHERE id = 1;
[Integer(1), Text("admin"), Text("admin@example.com")]
db > SELECT * FROM Users WHERE id = 42;
[Integer(42), Text("john.doe"), Text("john.doe@example.com")]
db > SELECT * FROM Users WHERE id = 666;
[Integer(666), Text("jane.doe"), Text("jane.doe@example.com")]
db >
Succès total et absolu !! 😍
Conclusion
Notre Query Engine a encore plein de problème et notre clef primaire ne supporte pas encore les clefs composites. Mais on a un début de quelque chose. ^^'
Dans la prochaine partie nous verrons les expressions qui permettront de faire des recherches plus intéressantes.
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.