Créer une extension Postgres en Rust, Episode 3 : Le chant du SIREN

Photo Credit Alexander Grey

Préambule

Cet article fait partie d'une série consacrée au framework PGRX, un environnement de développement qui facilite la conception d'extensions PostgreSQL avec le langage Rust.

Retrouvez les autres articles de la série ci-dessous:

Important: Cette série d'articles n'est pas une introduction au langage Rust ! Il existe d'autres ressources pour ça (notamment ici et ). On se focalisera ici sur la mécanique interne d'une extension Postgres.

Chaque épisode est conçu en 2 parties : un tutoriel de 30 minutes environ, suivi d'exercices pour aller plus loin. Les exercices sont optionnels.

Objectifs

Dans cet épisode, nous allons:

  • Créer un nouveau type de données pour Postgres
  • Définir des opérateurs pour ce type
  • Ecrire des tests contenant des requêtes SQL

Contexte

L'exemple proposé dans l'épisode précédent (cf. [Episode 2: Demander le Luhn]) a permis de voir comment utiliser une librairie Rust externe dans Postgres.

Nous entrons maintenant dans un usage plus spécifique: La création d'un nouveau type de données.

Cette fois, l'exemple va consiste à implémenter un type pour stocket les numéros SIREN, c'est à dire les identifiants des entreprises françaises. Ce numéro est souvent stocker comme un champs TEXT ayant le format 732 829 320 le dernier chiffre étant une somme de controle de la formule de Luhn.

L'idée ici est stocker la valeur sur disque comme un entier mais de manipuler comme une chaine de caractère

Démarrer

Si vous n'avez pas encore installer PGRX, reportez-vous à la section "Démarrage" de l'article "Episode 1 : Un nouveau monde".

ou sinon utilisez l'image docker:

docker run -it --volume `pwd`:/pgrx daamien/pgrx

Créer l'extension

On peut maintenant créer une extension nommée pg_siren avec:

cargo pgrx new pg_siren
cd pg_siren

Ajouter une dépendance externe

Pour cette extension, on aura besoin des librairies luhn3 et serde.

cargo add luhn3 serde

Créer le type

Le format habituel d'un numéro SIREN est 483 247 862. Il est souvent stocké dans les modèles de données dans une colonne de type TEXT.

Néanmoins les espaces ne sont que du formattage et le dernier chiffre est la somme de controle de luhn. On peut donc stocker la valeur comme un entier (48324786) et appliquer le formattage et la somme de controle au moment de l'affichage de la valeur.

Pour cela, on va stocker la valeur sous la forme d'un INTEGER, ce qui correspond au type i32 en Rust.

On crée donc une structure Siren comme ceci:

use pgrx::{InOutFuncs};
use serde::{Serialize, Deserialize};

pgrx::pg_module_magic!();

#[derive(PostgresType, Serialize, Deserialize, Debug)]
#[inoutfuncs]
pub struct Siren (i32);

Créer les fonctions d'entrée/sortie

On va maintenant créer les fonctions d'entrée et de sortie pour ce type


impl InOutFuncs for Siren {

impl InOutFuncs for Siren {
    // Parse the provided CStr into a Siren
    //   - Remove all spaces
    //   - Check the luhn checksum
    //   - Remove the luhn digit (division by 10)
    fn input(input: & core::ffi:CStr) -> Self {
        let val = input.to_str().expect("Invalid Input").replace(' ', "");
        if !luhn3::decimal::valid(&val.clone().into_bytes()) {
            error!("{}", "Not a valid SIREN number");
        }
        Siren(val.parse::<i32>().expect("Value should be a number") / 10)
    }

    // Output ourselves as text into the provided `StringInfo` buffer
    //   - Split the Integer value (self.0) in 3 parts
    //   - Add the luhn cheksum at the end
    fn output(&self, buffer: &mut pgrx::StringInfo) {
        let part1 = self.0 / 100000 % 1000;
        let part2 = self.0 / 100 % 1000;
        let part3 = self.0 % 100;
        let part4 = luhn3::decimal::checksum(&self.0.to_string().into_bytes())
            .expect("Checksum Failed") as char;
        let val = format!("{part1:03} {part2:03} {part3:02}{part4}");
        buffer.push_str(val.as_str());
    }
}

La fonction d'entrée prend une valeur TEXT (Cstr), elle supprime les espaces, vérifie la somme de controle puis retire le dernier chiffre (division par 10).

La fonction de sortie "découpe" la valeur numérique stockée (self.0) en 3 partie et ajoute la somme de controle à la fin.

Lancer l'extension

On peut ensuite directement tester l'extension

cargo pgrx run

Attention! Cette commande va compiler une centaine de paquets Rust (crates) et peut durer une dizaine de minutes !

CREATE EXTENSION pg_siren;

SELECT '483247862'::SIREN;
    siren
-------------
 483 247 862

SELECT '999 999 999'::SIREN;
ERROR:  Not a valid SIREN number
LINE 1: SELECT '999 999 999'::SIREN;

Ecrire un opérateur

Essayons maintenant de créer une table avec colone de type SIREN.

CREATE TABLE company (id SIREN PRIMARY KEY, name TEXT);

ERROR:  data type siren has no default operator class for access method "btree"
HINT:  You must specify an operator class for the index or define a default
       operator class for the data type.

C'est un échec, car le type SIREN n'a pas d'opérateurs de comparaison ce qui est nécessaire pour indexer les données.

PGRX vient nous simplifier la vie avec plusieurs macros qui vont créer automatiquement ces opérateurs. Pour cela, on ajoute 3 "dérivées" supplémentaires juste avant la déclaration du type SIREN.

#[derive(PostgresType, Serialize, Deserialize, Debug, PartialEq)]
#[derive(Eq, PartialEq, Ord, Hash, PartialOrd)]
#[derive(PostgresEq)]
#[derive(PostgresOrd)]
#[inoutfuncs]
pub struct Siren(i32);
  • derive(Eq, PartialEq, ...) génère les opérateurs Rust
  • derive(PostgresEq) génère l'opérateur Postgres d'égalité
  • derive(PostgresOrd) génère les opérateurs Postgres de comparaison (<, >, etc. )

On recompile ensuite l'extension

cargo pgrx run

et cette fois la création de la table fonctionne

DROP EXTENSION pg_siren;

CREATE EXTENSION pg_siren;

SELECT '483247862'::SIREN > '483247870'::SIREN ;

 ?column?
----------
 f
(1 row)

CREATE TABLE company (id SIREN PRIMARY KEY, name TEXT);

INSERT INTO company VALUES ('483247862','dalibo');

SELECT * FROM company;
     id      |  name
-------------+--------
 483 247 862 | dalibo

Ecrire des tests SQL

Cette fois pour tester, on ne peut se contenter d'appeler les fonctions Rust directement. On veut valider le type SQL fonctionne. Pour cela, on utilise le module Spi de PGRX.

#[cfg(any(test, feature = "pg_test"))]
#[pg_schema]
mod tests {
    use pgrx::prelude::*;

    #[pg_test]
    unsafe fn test_input() {
        Spi::run("SELECT '483247862'::SIREN").unwrap();
    }

    #[pg_test]
    unsafe fn test_create_table() {
        Spi::run("CREATE TABLE company (id SIREN PRIMARY KEY)").unwrap();
    }

    #[pg_test]
    #[should_panic]
    unsafe fn test_wrong_input() {
        Spi::run("SELECT '999 999 999'::SIREN").unwrap();
    }
}

Pour aller plus loin...

Voici quelques propositions d'activités pour poursuivre la découverte:

  • Ecrire une fonction random_siren() qui retourne une valeur aléatoire de type SIREN. On pourra pour cela utiliser le générateur aléatoire rand. Ecrire un test associé.

  • Le numéro SIRET est composé d'un numéro SIREN et d'un numéro d'établissement sur 5 chiffres. Créer un type composite et les fonctions entrée/sortie associées. Ecrire les tests associés.

Un exemple d'implémentation est disponible sur:

https://gitlab.com/daamien/pgrx-notebook/-/blob/main/pg_siren/src/lib.rs

Bilan

On voit que PGRX ne se contente pas de convertir à la volée les types Postgres en types rust (i.e INTEGER transformés en i32). Il permet également de créer ses propres types et de controler comment ce type sera stocké sur disque (input) et comment il sera affiché (output).

La suite au prochain épisode !

Dans le prochain épisode, nous allons jouer avec des crochets :)

=> Episode 4 : Accrocher le parapet