https://lafor.ge/feed.xml

Partie 10 : Clefs primaires

2024-12-18
Les articles de la série

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 ::=  [A-Za-z0-9_]*
LiteralInteger ::= [0-9]+
Constraint ::= 'PRIMARY' 'KEY' | 'NOT' 'NULL'
OpenParen ::= '('
CloseParen ::= ')'
Comma ::= ','
Semicolon ::= ';'

CreateTable:


ColumnDef:


ColumnType:


ColumTypeText:


LiteralString:


LiteralInteger:


Constraint:


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 ::=  "'" [A-Za-z0-9_]* "'"
Identifier ::=  [A-Za-z0-9_]*
LiteralInteger ::= [0-9]+
LiteralValue ::= LiteralInteger | LiteralString
OpenParen ::= '('
CloseParen ::= ')'
Comma ::= ','
Semicolon ::= ';'

Select:


Column:


WhereCaluse:


LiteralString:


Identifier:


LiteralInteger:


LiteralValue:


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
  • =
enum Token {
    // ...
    /// PRIMARY token
    Primary,
    /// KEY token
    Key,
    /// NOT token
    Not,
    /// NULL token
    Null,
    /// = token
    Equal,
    /// WHERE token
    Where,
    /// Technical token never matched
    Technic
    // ...
}

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:

impl Match for Token {
    fn matcher(&self) -> Matcher {
        match self {
            // ...
            Token::Primary => Box::new(matchers::match_pattern("primary")),
            Token::Key => Box::new(matchers::match_pattern("key")),
            Token::Not => Box::new(matchers::match_pattern("not")),
            Token::Null => Box::new(matchers::match_pattern("null")),
            Token::Equal => Box::new(matchers::match_char('=')),
            Token::Where => Box::new(matchers::match_pattern("where")),
            Token::Technic => Box::new(matchers::match_predicate(|_| (false, 0))),
            // ...
        }
    }
}

En n'oubliant pas de rajouter les implémentation de Size pour ces nouveaux tokens

impl Size for Token {
    fn size(&self) -> usize {
        match self {
            // ...
            Token::Primary => 7,
            Token::Key => 3,
            Token::Not => 3,
            Token::Null => 4,
            Token::Where => 5,
            // ...
        }
    }
}

Forecaster UntilEnd

Son travail est simple, reconnaître la fin de la slice.

pub struct UntilEnd;

impl<'a> Forecast<'a, u8, Token> for UntilEnd {
    fn forecast(&self, data: &mut Scanner<'a, u8>) -> crate::parser::Result<ForecastResult<Token>> {
        Ok(ForecastResult::Found {
            end_slice: data.remaining().len(),
            start: Token::Technic,
            end: Token::Technic,
        })
    }
}

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.

struct PrimaryKeyConstraint;

impl<'a> Visitable<'a, u8> for PrimaryKeyConstraint {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        recognize(Token::Primary, scanner)?;
        scanner.visit::<Whitespaces>()?;
        recognize(Token::Key, scanner)?;
        Ok(PrimaryKeyConstraint)
    }
}

On fait de même avec le NotNullConstraint

struct NotNullConstraint;

impl<'a> Visitable<'a, u8> for NotNullConstraint {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        scanner.visit::<OptionalWhitespaces>()?;
        recognize(Token::Not, scanner)?;
        scanner.visit::<Whitespaces>()?;
        recognize(Token::Null, scanner)?;
        Ok(NotNullConstraint)
    }
}

On fusionne les visiteurs dans une énumération

enum ConstraintResult {
    PrimaryKey(PrimaryKeyConstraint),
    NotNull(NotNullConstraint),
}

Et son pendant Constraint associé à sa glu technique.

enum Constraint {
    PrimaryKey,
    NotNull,
}

impl From<ConstraintResult> for Constraint {
    fn from(value: ConstraintResult) -> Self {
        match value {
            ConstraintResult::PrimaryKey(_constraint) => Constraint::PrimaryKey,
            ConstraintResult::NotNull(_constraint) => Constraint::NotNull,
        }
    }
}

On peut alors en faire un Visteur.

impl<'a> Visitable<'a, u8> for Constraint {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        Acceptor::new(scanner)
            .try_or(|constraint| Ok(ConstraintResult::PrimaryKey(constraint)))?
            .try_or(|constraint| Ok(ConstraintResult::NotNull(constraint)))?
            .finish()
            .ok_or(ParseError::UnexpectedToken)
            .map(Into::into)
    }
}

Ajout des contraintes sur la définition de la colonne

On rajoute la possibilité d'avoir des contraintes sur une colonne.

struct ColumnDefinition {
    name: String,
    field: ColumnType,
    // ici 👇
    constraints: Vec<Constraint>,
}

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
impl<'a> Visitable<'a, u8> for ColumnDefinition {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {

        // On reconnaît un groupe qui se termine
        let maybe_constraints = Forcaster::new(scanner)
            // soit par une virgule
            .try_or(UntilToken(Token::Comma))?
            // soit c'est la fin de la slice
            .try_or(UntilEnd)?
            .finish();

        let mut constraints = Vec::new();
        // si un groupe est trouvé
        if let Some(Forecasting { data, .. }) = maybe_constraints {
            let mut constrait_tokenizer = Tokenizer::new(data);
            // si le groupe contient au moins un byte
            if !constrait_tokenizer.remaining().is_empty() {
                // on en fait une liste de contraintes séparées par au moins un blanc
                let constraints_group =
                    constrait_tokenizer.visit::<SeparatedList<Constraint, Whitespaces>>()?;
                constraints = constraints_group.into_iter().collect();
                // on avance le scanner
                scanner.bump_by(data.len());
            }
        }

        // on retourne la colonne avec ses contraintes
        Ok(ColumnDefinition {
            name,
            field,
            constraints,
        })

    }
}

Modification du visiteur Schema

impl<'a> Schema {
    fn parse(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        // on visite la liste de colonnes
        let columns_definitions =
            fields_group_tokenizer.visit::<SeparatedList<ColumnDefinition, SeparatorComma>>()?;

        // que l'on transforme en des définitions de colonnes
        let fields =
            columns_definitions
                .into_iter()
                .fold(Vec::new(), |mut fields, column_definition| {
                    fields.push((
                        column_definition.name,
                        crate::data::ColumnDefinition::new(
                            column_definition.field,
                            // 👇 avec les contraintes associées 
                            column_definition.constraints,
                        ),
                    ));
                    fields
                });
    }
}

Ce qui donne:

#[test]
fn test_constraints() {
    let data = b"(id INTEGER NOT NULL PRIMARY KEY, name_1 TEXT(50) NOT NULL)";
    let mut tokenizer = Tokenizer::new(data);
    let schema = tokenizer.visit::<Schema>().expect("failed to parse schema");
    assert_eq!(
        schema,
        Schema::new(
            vec![
                (
                    "id".to_string(),
                    ColumnDefinition::new(
                        ColumnType::Integer,
                        vec![Constraint::NotNull, Constraint::PrimaryKey,]
                    )
                ),
                (
                    "name_1".to_string(),
                    ColumnDefinition::new(ColumnType::Text(50), vec![Constraint::NotNull])
                )
            ],
        )
    )
}

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

enum ParseError {
    // ...
    PrimaryKeyNotExists,
    NonUniquePrimaryKey(Vec<Vec<String>>),
}

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. 😇

fn parse_primary_key(
    fields: &Vec<(String, crate::data::ColumnDefinition)>,
) -> crate::parser::Result<Vec<String>> {
    let mut primary_keys = Vec::new();
    // on boucle sur les champs du schéma
    for (field_name, column_definition) in fields {
        // si au moins une des contraintes de la colonne est PRIMARY KEY
        if !column_definition
            .constraints
            .iter()
            .filter(|constraint| constraint == &&Constraint::PrimaryKey)
            .collect::<Vec<_>>()
            .is_empty()
        {
            // alors on rajoute le nom de la colonne
            primary_keys.push(vec![field_name.clone()]);
        }
    }

    // si aucune PRIMARY KEY n'est trouvable, le schéma est invalide
    if primary_keys.is_empty() {
        return Err(ParseError::PrimaryKeyNotExists);
    }
    // si plus d'un champ est une PRIMARY KEY, le schéma est également invalide
    if primary_keys.len() > 1 {
        return Err(ParseError::NonUniquePrimaryKey(primary_keys));
    }

    Ok(primary_keys.remove(0))
}

On défini un nouveau champ primary_key sur notre Schema.

struct Schema {
    pub fields: HashMap<String, ColumnDefinition>,
    pub columns: Vec<String>,
    // ici 👇
    pub primary_key: PrimaryKey,
}

Lors du parse on va alors appeler notre méthode parse_primary_key depuis notre visiteur de Schéma.

impl<'a> Schema {
    fn parse(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        // ...

        // on récupère la primary key
        let primary_key = parse_primary_key(&fields)?;

        Ok(Schema::new(fields, primary_key))
    }
}

Ce qui permet de parser nos schémas

#[test]
fn test_constraints() {
    let data = b"(id INTEGER NOT NULL PRIMARY KEY, name_1 TEXT(50) NOT NULL)";
    let mut tokenizer = Tokenizer::new(data);
    let schema = tokenizer.visit::<Schema>().expect("failed to parse schema");
    assert_eq!(
        schema,
        Schema::new(
            vec![
                (
                    "id".to_string(),
                    ColumnDefinition::new(
                        ColumnType::Integer,
                        vec![Constraint::NotNull, Constraint::PrimaryKey,]
                    )
                ),
                (
                    "name_1".to_string(),
                    ColumnDefinition::new(ColumnType::Text(50), vec![Constraint::NotNull])
                )
            ],
            vec!["id".to_string()]
        )
    )
}

#[test]
fn test_constraints_non_existent_primary_key() {
    let data = b"(id INTEGER NOT NULL, name_1 TEXT(50) NOT NULL)";
    let mut tokenizer = Tokenizer::new(data);
    let schema = tokenizer.visit::<Schema>();
    assert_eq!(schema, Err(crate::parser::ParseError::PrimaryKeyNotExists))
}

#[test]
fn test_constraints_multiple_primary_key() {
    let data = b"(id INTEGER NOT NULL PRIMARY KEY, name_1 TEXT(50) NOT NULL PRIMARY KEY)";
    let mut tokenizer = Tokenizer::new(data);
    let schema = tokenizer.visit::<Schema>();
    assert_eq!(
        schema,
        Err(crate::parser::ParseError::NonUniquePrimaryKey(vec![
            vec!["id".to_string()],
            vec!["name_1".to_string()]
        ]))
    )
}

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

struct WhereClause {
    field: String,
    value: Value,
}

Que l'on visite

impl<'a> Visitable<'a, u8> for WhereClause {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        scanner.visit::<OptionalWhitespaces>()?;
        // doit commencer par WHERE
        recognize(Token::Where, scanner)?;
        // suivi d'un espace
        scanner.visit::<Whitespaces>()?;
        // suivi d'un nom de colonne
        let column = scanner.visit::<Column>()?;
        // on reconnaît le = 
        recognize(Token::Equal, scanner)?;
        // suivi d'un espace
        scanner.visit::<Whitespaces>()?;
        // suivi d'une valeur
        let value = scanner.visit::<Value>()?;
        Ok(Self {
            field: column.0,
            value,
        })
    }
}

Ce qui donne

#[test]
fn test_where_clause() {
    let mut scanner = Scanner::new(b"WHERE id = 42");
    let where_clause = scanner.visit::<WhereClause>().unwrap();
    assert_eq!(where_clause.field, "id".to_string());
    assert_eq!(where_clause.value, Value::Integer(42));
}

Modification de la commande SELECT

On rajoute alors notre clause where qui peut optionnellement exister

struct SelectCommand {
    pub table_name: String,
    pub projection: Projection,
    // ici 👇
    pub where_clause: Option<WhereClause>,
}

Que l'on peut alors visiter

impl<'a> Visitable<'a, u8> for SelectCommand {
    fn accept(scanner: &mut Tokenizer<'a>) -> parser::Result<Self> {
        // ...

        
        // le modificateur d'optionalité permet de ne pas contraindre l'existence du WHERE
        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,
        })
    }
}

Donnant:

#[test]
fn test_parse_select_command_with_where_clause() {
    let data = b"SELECT * FROM table WHERE id = 1;";
    let mut tokenizer = super::Tokenizer::new(data);
    let result = tokenizer.visit::<SelectCommand>().expect("failed to parse");
    assert_eq!(
        result,
        SelectCommand {
            table_name: "table".to_string(),
            projection: Projection::Star,
            where_clause: Some(WhereClause {
                field: "id".to_string(),
                value: Value::Integer(1)
            })
        }
    );
}

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 = BTreeMap<Vec<Value>, usize>;

Notre "annuaire" sera stocké au niveau de la table

struct Table {
    schema: Schema,
    row_number: usize,
    pager: Pager,
    // ici 👇
    primary_indexes: BTreeMap<Vec<Value>, usize>,
}

impl Table {
    pub fn new(schema: Schema) -> Self {
        Self {
            pager: Pager::new(schema.size()),
            schema,
            row_number: 0,
            primary_indexes: BTreeMap::new(),
        }
    }
}

Lors de l'insertion, nous allons récupérer les valeurs des champs qui constituent la clef primaire de notre table.

fn get_pk_value(
    row: &HashMap<String, Value>,
    pk_fields: &Vec<String>,
) -> Result<Vec<Value>, InsertionError> {
    let mut pk = vec![];
    for pk_field in pk_fields {
        if let Some(value) = row.get(pk_field) {
            pk.push(value.clone());
        } else {
            return Err(InsertionError::PrimaryKeyNotExists(pk_fields.to_vec()));
        }
    }
    Ok(pk)
}

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.

impl Table {
    pub fn insert(&mut self, row: &HashMap<String, Value>) -> Result<(), InsertionError> {
        // récupération de la slice de données stockée par la page du tuple
        let page = self.pager.write(self.row_number);

        let mut writer = Cursor::new(page);
        // appel à la sérialisation au travers du schéma
        // la vérification est également faite à ce moment

        let pk_fields = &self.schema.primary_key;
        let pk = get_pk_value(row, pk_fields)?;
        
        // insertion dans l'index primaire de notre tuple 
        // et de l'ID courant d'insertion
        self.primary_indexes.insert(pk, self.row_number);

        self.schema
            .serialize(&mut writer, row)
            .map_err(InsertionError::Serialization)?;
        self.row_number += 1;
        Ok(())
    }
}

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.

pub struct QueryEngine<'a> {
    table: &'a Table,
}

impl<'a> QueryEngine<'a> {
    pub fn new(table: &'a Table) -> Self {
        Self { table }
    }
}

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

impl QueryEngine<'_> {
    pub fn full_scan(&self, row_number: usize) -> Result<Vec<Vec<Value>>, SelectError> {
        let mut rows = Vec::with_capacity(row_number);
        for row_number in 0..row_number {
            let page = self
                .table
                .pager
                .read(row_number)
                .ok_or(SelectError::PageNotExist(row_number))?;
            let mut reader = Cursor::new(page);
            rows.push(
                // désérialisation par le schéma
                self.table
                    .schema
                    .deserialize(&mut reader)
                    .map_err(SelectError::Deserialization)?,
            )
        }
        Ok(rows)
    }
}

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.

impl QueryEngine<'_> {
    fn get_row(&self, row_number: usize) -> Result<Vec<Value>, SelectError> {
        let page = self
            .table
            .pager
            .read(row_number)
            .ok_or(SelectError::PageNotExist(row_number))?;
        let mut reader = Cursor::new(page);
        let row = self
            .table
            .schema
            .deserialize(&mut reader)
            .map_err(SelectError::Deserialization)?;
        Ok(row)
    }
}

Et une méthode get_by_pk, le pk signifie "Primary Key".

impl QueryEngine<'_> {
    pub fn get_by_pk(
        &self,
        values: &Vec<Value>,
        primary_indexes: &PrimaryIndexes,
    ) -> Result<Vec<Vec<Value>>, SelectError> {
        // récupération l'ID du tuple
        let row_number = primary_indexes
            .get(values)
            .ok_or(SelectError::PrimaryKeyNotExists(values.clone()))?;
        // récupération de la page
        let row = self.get_row(*row_number)?;
        Ok(vec![row])
    }
}

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.

impl Table {
    pub fn select(
        &self,
        where_clause: Option<WhereClause>,
    ) -> Result<Vec<Vec<Value>>, SelectError> {
        // instanciation du Query Engine pour la table
        let engine = QueryEngine::new(self);

        match where_clause {
            // s'il n'y a pas de clause where on scan tout
            None => engine.full_scan(self.row_number),
            // sinon si la clause where concerne la clef primaire
            Some(WhereClause { field, value })
                if self.schema.primary_key == vec![field.clone()] =>
            {
                // on récupère l'entrée désignée
                engine.get_by_pk(&vec![value], &self.primary_indexes)
            }
            _ => Err(SelectError::NotImplemented),
        }
    }
}

Testons !!

On se créé une table qui possède une clef primaire "id".

db > CREATE TABLE Users (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à.

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.