generic_relation_helpers 0.2.0

Traits et helpers génériques pour jointures parent/enfant
Documentation
  • Coverage
  • 2.17%
    1 out of 46 items documented1 out of 33 items with examples
  • Size
  • Source code size: 74.26 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 2.69 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 34s Average build duration of successful builds.
  • all releases: 31s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • Dev-Skortana

generic_relation_helpers

Package Rust pour construire facilement des objets enrichis à partir de données liées entre elles — avec ou sans base de données.


À qui s'adresse ce package ?

Ce package s'adresse à tout développeur Rust qui travaille avec des données réparties dans plusieurs tables ou plusieurs listes, et qui souhaite les combiner proprement pour les utiliser ou les retourner via une API.


Le problème de départ — comprendre avant de coder

Imagine une plateforme scolaire

Tu développes une application pour une école. Les élèves s'inscrivent à des cours. Ces informations sont stockées dans une base de données, réparties dans 3 tables séparées :

Table Eleve
┌─────────────────┬───────────────────────┐
│ nom_complet     │ email                 │
├─────────────────┼───────────────────────┤
│ Lucas Martin    │ lucas@ecole.fr        │
│ Emma Dubois     │ emma@ecole.fr         │
│ Noah Bernard    │ noah@ecole.fr         │
└─────────────────┴───────────────────────┘

Table Cours
┌──────────────────┬──────────────────────────────┐
│ intitule         │ description                  │
├──────────────────┼──────────────────────────────┤
│ Mathématiques    │ Algèbre et géométrie          │
│ Informatique     │ Bases de la programmation     │
│ Anglais          │ Langue et littérature         │
└──────────────────┴──────────────────────────────┘

Table Inscription  ← table de liaison (qui suit quel cours ?)
┌─────────────────┬──────────────────┐
│ nom_eleve       │ intitule_cours   │
├─────────────────┼──────────────────┤
│ Lucas Martin    │ Mathématiques    │
│ Lucas Martin    │ Informatique     │
│ Emma Dubois     │ Informatique     │
│ Emma Dubois     │ Anglais          │
│ Noah Bernard    │ Mathématiques    │
└─────────────────┴──────────────────┘

Ton objectif : retourner à l'application ou à l'API un objet qui ressemble à ça :

[
  {
    "nom_complet": "Lucas Martin",
    "email": "lucas@ecole.fr",
    "cours": [
      { "intitule": "Mathématiques", "description": "Algèbre et géométrie"      },
      { "intitule": "Informatique",  "description": "Bases de la programmation" }
    ]
  },
  {
    "nom_complet": "Emma Dubois",
    "email": "emma@ecole.fr",
    "cours": [
      { "intitule": "Informatique", "description": "Bases de la programmation" },
      { "intitule": "Anglais",      "description": "Langue et littérature"      }
    ]
  },
  {
    "nom_complet": "Noah Bernard",
    "email": "noah@ecole.fr",
    "cours": [
      { "intitule": "Mathématiques", "description": "Algèbre et géométrie" }
    ]
  }
]

Chaque élève est accompagné de la liste détaillée de ses cours — pas juste leurs noms, mais toutes leurs informations complètes.


Sans le package — ce que tu dois écrire toi-même

Sans outil dédié, tu dois écrire manuellement le code qui croise les 3 listes entre elles. Voici à quoi ça ressemble :

// ── Étape 1 : charger les 3 tables séparément ──
let eleves       = vec![ /* ... données Eleve ... */ ];
let cours        = vec![ /* ... données Cours ... */ ];
let inscriptions = vec![ /* ... données Inscription ... */ ];

// ── Étape 2 : construire manuellement les objets combinés ──
let mut resultat: Vec<EleveAvecCours> = Vec::new();

for eleve in &eleves {
    // Pour chaque élève, trouver ses cours
    let mut cours_de_cet_eleve: Vec<Cours> = Vec::new();

    // Parcourir la table d'inscription
    for inscription in &inscriptions {
        if inscription.nom_eleve == eleve.nom_complet {
            // Cette inscription concerne cet élève
            // Chercher le cours correspondant dans la liste des cours
            for un_cours in &cours {
                if un_cours.intitule == inscription.intitule_cours {
                    cours_de_cet_eleve.push(un_cours.clone());
                }
            }
        }
    }

    // Construire l'objet combiné pour cet élève
    resultat.push(EleveAvecCours {
        nom_complet: eleve.nom_complet.clone(),
        email:       eleve.email.clone(),
        cours:       cours_de_cet_eleve,
    });
}

Les problèmes de cette approche

Problème 1 — C'est long à écrire. 3 boucles imbriquées pour une simple combinaison. Si ton application a 10 types de relations (élèves/cours, professeurs/classes, classes/salles...), tu écris ce code 10 fois.

Problème 2 — C'est fragile. Si tu renommes le champ nom_eleve en identifiant_eleve, tu dois retrouver et corriger ce code à la main — et espérer ne rien oublier.

Problème 3 — Ce n'est pas réutilisable. Ce code ne fonctionne que pour Eleve et Cours. Pour Professeur et Matiere, tu réécriras exactement les mêmes 3 boucles avec des noms différents.

Problème 4 — Difficile à lire. Un développeur qui découvre ce code doit décortiquer les 3 boucles imbriquées avant de comprendre ce que fait la fonction. L'intention — "combiner des élèves avec leurs cours" — est noyée dans les détails techniques.


Avec le package — ce que tu écris à la place

use generic_relation_helpers::propers_link_childrens_with_parent::get_parents_with_their_childrens;

// ── Étape 1 : charger les 3 tables séparément (identique) ──
let eleves       = vec![ /* ... données Eleve ... */ ];
let cours        = vec![ /* ... données Cours ... */ ];
let inscriptions = vec![ /* ... données Inscription ... */ ];

// ── Étape 2 : une seule ligne pour tout combiner ──
let resultat: Vec<EleveAvecCours> = get_parents_with_their_childrens(
    eleves,         // ← les parents (élèves)
    &inscriptions,  // ← la table de liaison
    &cours,         // ← les enfants enrichis (cours complets)
    None,           // ← pas de filtre
    None,
);

C'est tout. Le package fait les 3 boucles à ta place, de manière générique, pour n'importe quel type de données.


La différence en un coup d'œil

Sans le package                    Avec le package
─────────────────────────────────  ──────────────────────────────────
~20 lignes de code par relation    1 ligne par relation
À réécrire pour chaque entité      Réutilisable pour tous les types
Logique mêlée au métier            Logique séparée et invisible
Risque d'oubli ou d'erreur         Testé et fiable

Comment le package sait quoi combiner ?

Le package ne connaît pas tes structs à l'avance. Tu lui expliques une seule fois, via de petits traits (des fiches d'identité), comment accéder aux informations dont il a besoin.

C'est quoi un trait ? En Rust, un trait est un contrat que tu fais signer à une struct. Tu dis : "cette struct sait répondre à ces questions". Une fois le contrat rempli, le package peut utiliser ta struct sans la connaître à l'avance.

Voici les fiches à remplir pour notre exemple école :

// ── Fiche 1 : "Comment identifier un élève ?" ──
impl GetValuePropertyOfKeyParent for Eleve {
    fn get_value(&self) -> &str {
        &self.nom_complet   // ← la clé d'identification de l'élève
    }
}

// ── Fiche 2 : "Quels champs exposer pour l'élève ?" ──
impl HasFields for Eleve {
    fn field_names() -> Vec<&'static str> {
        vec!["nom_complet", "email"]
    }
    fn get_fields(&self, name: &str) -> Value {
        match name {
            "nom_complet" => json!(self.nom_complet),
            "email"       => json!(self.email),
            _             => Value::Null,
        }
    }
}

// ── Fiche 3 : "Comment appeler la liste des cours dans l'objet final ?" ──
impl GetValueFieldForSourceParent<Cours> for Eleve {
    fn enfants_key() -> &'static str { "cours" }
    //                                  ↑ ce sera le nom de la clé dans le JSON retourné
}

// ── Fiche 4 : "Comment relier une inscription à un cours ?" ──
impl HasMatchKey for Cours {
    fn get_match_key(&self) -> &str {
        &self.intitule   // ← le cours est identifié par son intitulé
    }
}

// ── Fiche 5 : "Quelle colonne de la table Inscription pointe vers l'élève,
//              et laquelle pointe vers le cours ?" ──
impl GetValuePKFieldForSourceSecondary for Inscription {
    fn get_fk_field_of_source_secondary(&self) -> &str { &self.nom_eleve      }
    fn get_field_of_source_secondary(&self)    -> &str { &self.intitule_cours  }
}

// ── Fiche 6 : "Comment construire un objet Cours depuis une HashMap ?" ──
impl BuildFromSon for Cours {
    fn build(fields: HashMap<String, Value>) -> Self {
        Cours {
            intitule:    get_string(&fields, "intitule"),
            description: get_string_opt(&fields, "description"),
        }
    }
}

// ── Fiche 7 : "Comment construire l'objet final EleveAvecCours ?" ──
impl BuildFromParent for EleveAvecCours {
    fn build(fields: HashMap<String, Value>) -> Self {
        EleveAvecCours {
            nom_complet: get_string(&fields, "nom_complet"),
            email:       get_string_opt(&fields, "email"),
            cours:       fields.get("cours")
                .and_then(|v| v.as_array()).unwrap_or(&vec![])
                .iter()
                .map(|v| Cours::build(
                    serde_json::from_value::<HashMap<String, Value>>(v.clone()).unwrap()
                )).collect(),
        }
    }
}

Tu remplis ces fiches une seule fois par type de relation. Ensuite, tu appelles get_parents_with_their_childrens autant de fois que tu veux, pour autant de relations que tu as dans ton application.


Les types de relations supportées ✅

Type 1 — Relation N:N via table de liaison

Un élève suit plusieurs cours, un cours accueille plusieurs élèves.

Eleve ──── Inscription ──── Cours
            (table de liaison)
get_parents_with_their_childrens(eleves, &inscriptions, &cours, None, None)

Type 2 — Relation 1:N avec FK directe dans l'enfant

Un professeur publie plusieurs articles, chaque article appartient à un seul professeur.

Professeur ──── Article
                 (champ nom_professeur dans Article)

Il n'y a pas de table de liaison séparée. Article contient directement le nom du professeur. On passe donc Article deux fois :

let articles       = /* charger Article */;
let articles_clone = articles.clone(); // ← copie nécessaire

get_parents_with_their_childrens(professeurs, &articles, &articles_clone, None, None)
//                                             ↑ filtre   ↑ données enrichies

Type 3 — Relation N:N avec attributs sur la liaison

Un élève obtient une note et une date d'examen pour chaque cours — ces informations sont des attributs de la liaison, pas du cours lui-même.

                                      ┌─── Cours ───────────────────────────┐
                                      │ intitule:    "Mathématiques"         │
Eleve ──── Inscription ──────────────>│ description: "Algèbre et géométrie"  │
            note: 17                  └─────────────────────────────────────┘
            date_examen: 2025-06-15

note et date_examen appartiennent à Inscription. intitule et description appartiennent à Cours.

Les attributs de Inscription (note, date_examen) sont fusionnés automatiquement dans l'objet Cours retourné — sans code supplémentaire de ta part. Le résultat final combine les champs des deux tables :

{
  "nom_complet": "Lucas Martin",
  "cours": [
    {
      "intitule": "Mathématiques",
      "description": "Algèbre et géométrie",
      "note": 17,
      "date_examen": "2025-06-15"
    }
  ]
}

Les types de relations non supportées ❌

Certaines configurations de relations entre tables sont en dehors du périmètre de ce package. Voici lesquelles, et pourquoi.


❌ Relation 1:1 — deux tables liées par une seule correspondance

Exemple : Un utilisateur a exactement un profil, un profil appartient à exactement un utilisateur.

Utilisateur ──── Profil
                  (un seul profil par utilisateur)

Pourquoi non supporté ? Le package est conçu pour associer un parent à une liste d'enfants (Vec<TEnfant>). Dans une relation 1:1, il n'y a pas de liste — juste un objet unique en face. Utiliser le package pour ça fonctionnerait techniquement mais retournerait un Vec avec un seul élément, ce qui est sémantiquement incorrect et inutilement complexe.

Contournement : Une simple requête avec jointure ou un accès direct suffit.


❌ Relation récursive — une table liée à elle-même

Exemple : Un employé a un manager, qui est lui-même un employé de la même table.

Employe ──── Employe
  id: 1        id: 2  (manager de l'employé 1)
  manager_id: 2

Pourquoi non supporté ? Le package distingue clairement un type "parent" et un type "enfant". Ici, Employe joue les deux rôles simultanément — parent et enfant sont la même struct dans la même table. Le système de traits génériques ne peut pas résoudre cette ambiguïté sans conflit de types.

Contournement : Une requête SQL récursive (WITH RECURSIVE) ou une fonction Rust dédiée qui parcourt l'arborescence manuellement.


❌ Relation ternaire — 3 tables liées simultanément par une seule liaison

Exemple : Un professeur enseigne un cours dans une salle spécifique — les trois sont liés en même temps dans une seule table de liaison.

Professeur ──── Planning ──── Cours
                    │
                  Salle
Table Planning
┌──────────────────┬──────────────────┬───────────┐
│ nom_professeur   │ intitule_cours   │ nom_salle │
├──────────────────┼──────────────────┼───────────┤
│ M. Durand        │ Mathématiques    │ Salle A   │
└──────────────────┴──────────────────┴───────────┘

Pourquoi non supporté ? Le package est conçu pour relier deux entités entre elles (un parent et ses enfants). Une relation ternaire implique 3 entités liées simultanément dans la même ligne de jointure. La fonction get_parents_with_their_childrens n'accepte qu'un seul type de parent et un seul type d'enfant — pas trois.

Contournement : Décomposer en deux appels successifs — d'abord lier Professeur à Cours, puis enrichir chaque Cours avec sa Salle.


❌ Relation polymorphique — une liaison qui pointe vers plusieurs types différents

Exemple : Un commentaire peut appartenir soit à un article, soit à une vidéo, soit à une photo — selon un champ type_cible.

Commentaire
  cible_id:   42
  type_cible: "Article"   ← ou "Video", ou "Photo"

Pourquoi non supporté ? Rust est un langage à typage statique fort. Le package doit connaître à la compilation le type exact du parent et de l'enfant. Une relation polymorphique implique que le type de la cible varie à l'exécution selon la valeur d'un champ — ce que le système de types de Rust ne peut pas résoudre avec des génériques simples.

Contournement : Gérer chaque type de cible séparément avec trois appels distincts, filtrés par type_cible.


❌ Relation avec héritage de table — parent et enfants dans des tables séparées par sous-type

Exemple : Une table Personne avec des sous-types Etudiant et Enseignant dans des tables séparées.

Personne ──── Etudiant   (données spécifiques à l'étudiant)
         └─── Enseignant (données spécifiques à l'enseignant)

Pourquoi non supporté ? Ce pattern (appelé "héritage de table" ou table inheritance) suppose qu'une même entité est décrite par plusieurs tables selon son sous-type. Le package ne gère qu'une seule table source par rôle (parent, liaison, enfant) — il ne sait pas fusionner deux tables pour reconstituer un objet complet avant de l'utiliser.

Contournement : Reconstituer manuellement les structs complètes avant de les passer au package, ou utiliser une vue SQL qui fusionne les tables.


Résumé des cas supportés et non supportés

Type de relation Supporté
N:N via table de liaison
1:N avec FK directe dans l'enfant
N:N avec attributs sur la liaison
1:1 (un parent, un enfant unique)
Récursive (une table liée à elle-même)
Ternaire (3 entités liées en même temps)
Polymorphique (type de cible variable)
Héritage de table (sous-types séparés)

Le filtrage intégré

Chaque appel peut être filtré directement, sans requête supplémentaire vers la base de données :

// Retourner uniquement Lucas Martin
get_parents_with_their_childrens(
    eleves, &inscriptions, &cours,
    Some("equal".to_string()),
    Some("Lucas Martin".to_string())
)

// Retourner tous les élèves dont le nom commence par "E"
get_parents_with_their_childrens(
    eleves, &inscriptions, &cours,
    Some("begin".to_string()),
    Some("E".to_string())
)
Mode Comportement Exemple
None Retourne tout
"equal" Nom exactement égal "Lucas Martin" → Lucas Martin
"begin" Nom commence par... "Em" → Emma Dubois
"contains" Nom contient... "oa" → Noah Bernard
"finished" Nom se termine par... "Dubois" → Emma Dubois

Tous les filtres sont insensibles à la casse"lucas martin" trouve "Lucas Martin".


Utilisation sans base de données

Le package fonctionne avec n'importe quelle source de données — pas seulement une base de données relationnelle.

// ── Depuis une API externe ──
let eleves:       Vec<Eleve>       = appel_api("https://api.ecole.fr/eleves").await;
let cours:        Vec<Cours>       = appel_api("https://api.ecole.fr/cours").await;
let inscriptions: Vec<Inscription> = appel_api("https://api.ecole.fr/inscriptions").await;

let resultat = get_parents_with_their_childrens(eleves, &inscriptions, &cours, None, None);


// ── Depuis des données en mémoire (tests unitaires) ──
let eleves = vec![
    Eleve { nom_complet: "Lucas Martin".into(), email: "lucas@ecole.fr".into() }
];
let cours = vec![
    Cours { intitule: "Mathématiques".into(), description: Some("Algèbre".into()) }
];
let inscriptions = vec![
    Inscription { nom_eleve: "Lucas Martin".into(), intitule_cours: "Mathématiques".into() }
];

let resultat = get_parents_with_their_childrens(eleves, &inscriptions, &cours, None, None);


// ── Depuis Diesel + PostgreSQL ──
let eleves       = eleve.load::<Eleve>(&mut conn).unwrap();
let cours        = cours_table.load::<Cours>(&mut conn).unwrap();
let inscriptions = inscription.load::<Inscription>(&mut conn).unwrap();

let resultat = get_parents_with_their_childrens(eleves, &inscriptions, &cours, None, None);

Dans les trois cas, l'appel get_parents_with_their_childrens est identique — seule la source des données change.


Fonctions disponibles

// ── Combiner une liste de parents avec leurs enfants ──
pub fn get_parents_with_their_childrens(
    source_parent:  Vec<ParentSource>,  // ← ex: Vec<Eleve>
    source_son:     &Vec<SourceSon>,    // ← ex: &Vec<Inscription>  (liaison)
    source_enfants: &Vec<TEnfant>,      // ← ex: &Vec<Cours>        (données enrichies)
    type_filter:    Option<String>,     // ← "equal" | "begin" | "contains" | "finished" | None
    filter_by_name: Option<String>,     // ← valeur du filtre
) -> Vec<SourceDestination>            // ← ex: Vec<EleveAvecCours>


// ── Récupérer les enfants d'un seul parent ──
pub fn get_childrens_of_parent(
    parent_single:  &impl GetValuePropertyOfKeyParent, // ← un seul élève
    jointure_many:  &Vec<TJointure>,                   // ← la table d'inscription
    enfants_source: &Vec<TEnfant>,                     // ← les cours
) -> Vec<TEnfant>                                      // ← les cours de cet élève

Helpers de conversion inclus

Pour lire les valeurs depuis une HashMap dans tes implémentations de build() :

get_string(&fields, "nom_complet")     // → String         (chaîne vide si absent)
get_string_opt(&fields, "email")       // → Option<String> (None si absent)
get_bool(&fields, "actif")             // → bool           (false si absent)
get_i64(&fields, "note")               // → i64            (0 si absent)
get_f64(&fields, "moyenne")            // → f64            (0.0 si absent)
get_time(&fields, "heure_cours")       // → Option<NaiveTime>
get_date(&fields, "date_examen")       // → Option<NaiveDate>
get_datetime(&fields, "created_at")    // → Option<NaiveDateTime>

Installation

[dependencies]
generic_relation_helpers = "0.1.0"

Compatibilité testée

Source de données Statut
Diesel + PostgreSQL ✅ Testé
Données en mémoire (tests) ✅ Testé
API externe (JSON désérialisé) ✅ Compatible
SQLx ✅ Compatible
Fichiers CSV / JSON ✅ Compatible

Licence

MIT