đŸ› ïž Atelier · Data Vault 2.0

🏩 Data Vault 2.0 — modĂ©liser un entrepĂŽt qui encaisse le changement

Bienvenue dans cet atelier oĂč tu vas construire un Data Vault de tes mains, table par table, sur des donnĂ©es de banque. Pas de thĂ©orie hors-sol : chaque notion est suivie d'un exercice que tu exĂ©cutes, et d'une cellule qui vĂ©rifie ton travail.

🎯 L'idĂ©e en une phrase : Data Vault sĂ©pare ce que les autres modĂšles mĂ©langent — l'identitĂ© d'un client (le hub), ses relations (le link) et sa description dans le temps (le satellite). RĂ©sultat : une nouvelle source s'ajoute sans rien casser, et chaque chiffre garde la trace de son origine.


Prérequis

Module Compétence Pourquoi ?
✅ 07 SQL pour Data Engineers Tout l'atelier est en SQL (INSERT, JOIN, fonctions de fenĂȘtre)
💡 25 dbt & Data Quality Les notions de staging et de couches (utile, pas indispensable)

Rien d'autre : cet atelier part de zéro sur Data Vault.

Objectifs

À la fin de cet atelier, tu seras capable de :

  • Expliquer le rĂŽle des hubs, links et satellites, et pourquoi on les sĂ©pare
  • Calculer des clĂ©s de hachage fiables (et Ă©viter le piĂšge de la normalisation)
  • Écrire des chargements insert-only, rejouables sans risque
  • Historiser une description avec une empreinte (hash_diff)
  • DĂ©crire et dater une relation (satellite de link, satellite d'effectivitĂ©)
  • Retrouver ce que l'entrepĂŽt savait Ă  une date donnĂ©e, et l'accĂ©lĂ©rer avec une table PIT
  • Construire un data mart en Ă©toile au-dessus du Data Vault
  • Dire quand Data Vault vaut son coĂ»t
 et quand il ne le vaut pas

Le fil rouge

Une banque. Trois systĂšmes dĂ©crivent les mĂȘmes clients : le logiciel des agences, l'application mobile, et le fichier des comptes. Leurs livraisons arrivent par lots, Ă  des dates diffĂ©rentes, avec leurs dĂ©fauts. Tu vas les intĂ©grer dans ce modĂšle :

sat_client_agences  sat_client_mobile           sat_compte
         \              /                          |
          hub_client ─── link_client_compte ─── hub_compte
                                 |
        sat_lien_client_compte · sat_eff_client_compte

   HUB       = l'identité (une ligne par clé métier, pour toujours)
   LINK      = la relation entre deux hubs
   SATELLITE = la description, historisée

1. Le problÚme que Data Vault résout

1.1 Une table client
 et trois sources

Dans un schéma en étoile classique, on fusionne tout ce qu'on sait d'un client dans une dimension :

 logiciel des agences ──┐
 application mobile ────┌──▶  dim_client  (tout fusionnĂ©)
 systĂšme de crĂ©dit ─────┘

C'est parfait pour un tableau de bord. Mais pour un entrepÎt d'intégration, trois problÚmes apparaissent vite :

ProblĂšme Ce qui se passe
🔹 Une 4ᔉ source arrive Il faut modifier dim_client, ses chargements, et tout ce qui en dĂ©pend
đŸ•łïž Une valeur change Si on l'Ă©crase, l'ancienne est perdue. Si on historise tout dans la mĂȘme table, elle grossit de façon ingĂ©rable
🔍 L'auditeur demande « d'oĂč vient ce chiffre ? » Une ligne fusionnĂ©e ne dit pas quelle source a fourni quel attribut, ni quand

1.2 La réponse : séparer trois questions

Question Type de table Contient
Qui ? Hub La clé métier, et rien d'autre
Lié à quoi ? Link Les clés des hubs reliés
Comment est-il, à chaque date ? Satellite Les attributs descriptifs, historisés

Deux rĂšgles s'appliquent Ă  toutes les tables :

  1. Chaque ligne porte sa date de chargement et sa source. C'est ce qui rend l'entrepĂŽt auditable.
  2. On ne fait qu'ajouter des lignes. Jamais d'UPDATE, jamais de DELETE : l'historique est complet par construction.

2. Mise en place

2.1 Installer DuckDB

On travaille avec DuckDB : une base SQL complĂšte qui tourne dans le notebook, sans serveur. C'est le mĂȘme moteur que dans le mini-projet du module 25.

pythonVoir le code
%pip install -q duckdb pandas
pythonVoir le code
import re
import duckdb

con = duckdb.connect()  # base en mémoire : rien à installer, rien à nettoyer


def voir(sql):
    """ExĂ©cute une requĂȘte et affiche le rĂ©sultat en tableau."""
    try:
        return con.sql(sql).df()
    except duckdb.CatalogException as e:  # table pas encore créée par un exercice
        print("⏳", str(e).splitlines()[0])


def val(sql):
    """Renvoie la premiĂšre valeur d'une requĂȘte (pour les vĂ©rifications)."""
    ligne = con.sql(sql).fetchone()
    return ligne[0] if ligne else None


def executer(sql):
    """ExĂ©cute ton SQL — ou te prĂ©vient si l'exercice n'est pas encore rempli."""
    sans_commentaires = re.sub(r"--[^\n]*", "", sql).strip()
    if not sans_commentaires:
        print("⏳ Exercice pas encore fait : Ă©cris ton SQL Ă  la place des commentaires,\n"
              "   puis relance la cellule.")
        return
    con.execute(sql)


def verifier(titre, tests):
    """Lance une liste de (description, test) et affiche ✅ / ❌ pour chacun."""
    tout_ok = True
    for description, test in tests:
        try:
            ok = bool(test())
        except Exception as e:  # table absente, colonne manquante

            ok, description = False, f"{description}  ({type(e).__name__})"
        print(("✅ " if ok else "❌ ") + description)
        tout_ok &= ok
    if tout_ok:
        print(f"\n🎉 {titre} rĂ©ussi !")
    else:
        print(f"\nâ†Ș {titre} : pas encore. Relis l'Ă©noncĂ©, ou ouvre la solution.")


print("✅ DuckDB", duckdb.__version__, "prĂȘt")

2.2 Les livraisons des sources

Voici ce que la zone de staging a reçu. Chaque source livre un fichier complet à chaque lot, et chaque ligne porte la date du lot (date_chargement).

Table Source Lots
stg_agences_clients logiciel des agences 5 janvier, 18 juin
stg_mobile_clients application mobile 11 février
stg_agences_comptes logiciel des agences 5 janvier, 2 avril, 1er septembre

Regarde bien l'application mobile : ses numĂ©ros clients sont saisis Ă  la main. 😬

pythonVoir le code
con.execute("""
CREATE OR REPLACE TABLE stg_agences_clients AS
SELECT * FROM (VALUES
    -- lot du 5 janvier
    ('C-1042', 'Kouassi Ama',   'Abidjan',      DATE '2026-01-05'),
    ('C-1043', 'Traoré Moussa', 'Yamoussoukro', DATE '2026-01-05'),
    ('C-1045', 'Koné Awa',      'San-Pédro',    DATE '2026-01-05'),
    -- lot du 18 juin : Kouassi Ama a déménagé, les deux autres n'ont pas changé
    ('C-1042', 'Kouassi Ama',   'Bouaké',       DATE '2026-06-18'),
    ('C-1043', 'Traoré Moussa', 'Yamoussoukro', DATE '2026-06-18'),
    ('C-1045', 'Koné Awa',      'San-Pédro',    DATE '2026-06-18')
) AS t(numero_client, nom, ville, date_chargement);

CREATE OR REPLACE TABLE stg_mobile_clients AS
SELECT * FROM (VALUES
    -- lot du 11 février : numéros saisis à la main dans l'appli
    (' c-1042', '+225 07 00 00 00 01', 'ama.kouassi@mail.test',  DATE '2026-02-11'),
    ('C-1043',  '+225 05 00 00 00 02', NULL,                     DATE '2026-02-11'),
    ('C-1044 ', '+225 01 00 00 00 03', 'yao.bamba@mail.test',    DATE '2026-02-11')
) AS t(numero_client, telephone, email, date_chargement);

CREATE OR REPLACE TABLE stg_agences_comptes AS
SELECT * FROM (VALUES
    -- lot du 5 janvier : qui détient quel compte, et à quel titre
    ('CPT-501', 'C-1042', 'courant', 'actif', 'titulaire',    DATE '2026-01-05'),
    ('CPT-502', 'C-1042', 'épargne', 'actif', 'titulaire',    DATE '2026-01-05'),
    ('CPT-503', 'C-1045', 'courant', 'actif', 'titulaire',    DATE '2026-01-05'),
    -- lot du 2 avril : CPT-502 devient un compte joint, C-1043 en est co-titulaire
    ('CPT-501', 'C-1042', 'courant', 'actif', 'titulaire',    DATE '2026-04-02'),
    ('CPT-502', 'C-1042', 'épargne', 'actif', 'titulaire',    DATE '2026-04-02'),
    ('CPT-502', 'C-1043', 'épargne', 'actif', 'co-titulaire', DATE '2026-04-02'),
    ('CPT-503', 'C-1045', 'courant', 'actif', 'titulaire',    DATE '2026-04-02'),
    -- lot du 1er septembre : C-1043 a quitté le compte joint
    ('CPT-501', 'C-1042', 'courant', 'actif', 'titulaire',    DATE '2026-09-01'),
    ('CPT-502', 'C-1042', 'épargne', 'actif', 'titulaire',    DATE '2026-09-01'),
    ('CPT-503', 'C-1045', 'courant', 'actif', 'titulaire',    DATE '2026-09-01')
) AS t(numero_compte, numero_client, type_compte, statut, role, date_chargement);
""")

voir("SELECT * FROM stg_mobile_clients")

2.3 Les vérifications automatiques

Chaque exercice se termine par une ligne verifier_exercice(n) qui teste ton travail et affiche ✅ ou ❌ pour chaque point. Les tests sont regroupĂ©s dans la cellule ci-dessous : exĂ©cute-la une fois. Tu n'as pas besoin de la lire — mais rien ne t'en empĂȘche, chaque test dit exactement ce qu'il attend.

pythonVoir le code
import hashlib


def md5_py(texte):
    return hashlib.md5(texte.encode()).hexdigest()


def lignes(sql):
    return con.sql(sql).fetchall()


# états attendus, réutilisés pour vérifier qu'une table n'a PAS bougé
HUB_CLIENTS = [("C-1042", "2026-01-05", "agences"), ("C-1043", "2026-01-05", "agences"),
               ("C-1044", "2026-02-11", "mobile"),  ("C-1045", "2026-01-05", "agences")]
VERSIONS_AGENCES = [("C-1042", "2026-01-05", "Kouassi Ama", "Abidjan"),
                    ("C-1042", "2026-06-18", "Kouassi Ama", "Bouaké"),
                    ("C-1043", "2026-01-05", "Traoré Moussa", "Yamoussoukro"),
                    ("C-1045", "2026-01-05", "Koné Awa", "San-Pédro")]


ETAT_1ER_MARS = [("C-1042", "Kouassi Ama", "Abidjan", "+225 07 00 00 00 01", "ama.kouassi@mail.test"),
                 ("C-1043", "Traoré Moussa", "Yamoussoukro", "+225 05 00 00 00 02", None),
                 ("C-1044", None, None, "+225 01 00 00 00 03", "yao.bamba@mail.test"),
                 ("C-1045", "Koné Awa", "San-Pédro", None, None)]


def versions_agences():
    return lignes("""SELECT h.numero_client, s.date_chargement::VARCHAR, s.nom, s.ville
                     FROM sat_client_agences s JOIN hub_client h USING (hk_client) ORDER BY 1, 2""")


def colonnes(table):
    """Les colonnes d'une table ou d'une vue, dans l'ordre."""
    return [r[0] for r in con.sql(f"SELECT column_name FROM information_schema.columns "
                                  f"WHERE table_name = '{table}' ORDER BY ordinal_position").fetchall()]


def _exercice_1():
    return [
        ("la table hub_client existe",
         lambda: val("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'hub_client'") == 1),
        ("colonnes : hk_client, numero_client, date_chargement, source",
         lambda: colonnes("hub_client") == ["hk_client", "numero_client", "date_chargement", "source"]),
        ("hk_client est la clé primaire",
         lambda: val("""SELECT COUNT(*) FROM duckdb_constraints()
                        WHERE table_name = 'hub_client' AND constraint_type = 'PRIMARY KEY'
                          AND constraint_column_names = ['hk_client']""") == 1),
        ("aucune colonne n'accepte NULL",
         lambda: val("""SELECT COUNT(*) FROM information_schema.columns
                        WHERE table_name = 'hub_client' AND is_nullable = 'YES'""") == 0),
    ]


def _exercice_2():
    return [
        ("v_stg_agences_clients : bonnes colonnes",
         lambda: colonnes("v_stg_agences_clients") ==
                 ["hk_client", "numero_client", "nom", "ville", "date_chargement", "source"]),
        ("v_stg_mobile_clients : bonnes colonnes",
         lambda: colonnes("v_stg_mobile_clients") ==
                 ["hk_client", "numero_client", "telephone", "email", "date_chargement", "source"]),
        ("toutes les clés métier sont normalisées (C-xxxx)",
         lambda: val("""SELECT COUNT(*) FROM (SELECT numero_client FROM v_stg_agences_clients
                        UNION ALL SELECT numero_client FROM v_stg_mobile_clients)
                        WHERE NOT regexp_full_match(numero_client, 'C-[0-9]{4}')""") == 0),
        ("' c-1042' (mobile) et 'C-1042' (agences) ont la mĂȘme clĂ© de hachage",
         lambda: val("SELECT hk_client FROM v_stg_mobile_clients WHERE numero_client = 'C-1042'") ==
                 val("SELECT DISTINCT hk_client FROM v_stg_agences_clients WHERE numero_client = 'C-1042'")),
        ("la clé de hachage est bien le MD5 de la clé normalisée",
         lambda: val("SELECT COUNT(*) FROM v_stg_mobile_clients WHERE hk_client <> md5(numero_client)") == 0),
        ("la colonne source vaut 'agences' / 'mobile'",
         lambda: val("SELECT string_agg(DISTINCT source, ',') FROM v_stg_agences_clients") == "agences"
                 and val("SELECT string_agg(DISTINCT source, ',') FROM v_stg_mobile_clients") == "mobile"),
    ]


def _exercice_3():
    def hub():
        return con.sql("SELECT numero_client, date_chargement::VARCHAR, source "
                       "FROM hub_client ORDER BY numero_client").fetchall()

    def rejouer_tout():
        avant = hub()
        for vue, lot in LOTS_CLIENTS:
            charger_hub_client(vue, lot)
        return hub() == avant

    return [
        ("4 clients dans le hub, ni plus ni moins", lambda: len(hub()) == 4),
        ("chaque client garde la date et la source de sa premiĂšre apparition", lambda: hub() == HUB_CLIENTS),
        ("rejouer les trois lots ne change rien (chargement idempotent)", rejouer_tout),
    ]


def _exercice_4():
    return [
        ("bonnes colonnes, dans le bon ordre",
         lambda: colonnes("v_stg_agences_comptes") ==
                 ["hk_lien", "hk_client", "hk_compte", "numero_client", "numero_compte",
                  "type_compte", "statut", "role", "date_chargement", "source"]),
        ("une ligne par ligne de staging",
         lambda: val("SELECT COUNT(*) FROM v_stg_agences_comptes") == val("SELECT COUNT(*) FROM stg_agences_comptes")),
        ("hk_lien = md5('C-1042||CPT-501') pour ce couple",
         lambda: val("""SELECT DISTINCT hk_lien FROM v_stg_agences_comptes
                        WHERE numero_client = 'C-1042' AND numero_compte = 'CPT-501'""") == md5_py("C-1042||CPT-501")),
        ("hk_client identique Ă  celui du staging des clients",
         lambda: val("SELECT DISTINCT hk_client FROM v_stg_agences_comptes WHERE numero_client = 'C-1042'") ==
                 val("SELECT DISTINCT hk_client FROM v_stg_agences_clients WHERE numero_client = 'C-1042'")),
        ("hk_compte = md5 du numéro de compte",
         lambda: val("SELECT COUNT(*) FROM v_stg_agences_comptes WHERE hk_compte <> md5(numero_compte)") == 0),
        ("source = 'agences'",
         lambda: val("SELECT string_agg(DISTINCT source, ',') FROM v_stg_agences_comptes") == "agences"),
    ]


def _exercice_5():
    def couples():
        return lignes("""SELECT c.numero_client, k.numero_compte, l.date_chargement::VARCHAR
                         FROM link_client_compte l
                         JOIN hub_client c USING (hk_client) JOIN hub_compte k USING (hk_compte)
                         ORDER BY 1, 2""")

    def rejouer_tout():
        avant = (couples(), lignes("SELECT * FROM hub_compte ORDER BY 1"))
        for lot in LOTS_COMPTES:
            charger_hub_compte(lot)
            charger_link_client_compte(lot)
        return (couples(), lignes("SELECT * FROM hub_compte ORDER BY 1")) == avant

    return [
        ("hub_compte : 3 comptes, découverts le 5 janvier",
         lambda: lignes("SELECT numero_compte, date_chargement::VARCHAR FROM hub_compte ORDER BY 1") ==
                 [("CPT-501", "2026-01-05"), ("CPT-502", "2026-01-05"), ("CPT-503", "2026-01-05")]),
        ("link_client_compte ne contient que des clés (5 colonnes)",
         lambda: colonnes("link_client_compte") == ["hk_lien", "hk_client", "hk_compte", "date_chargement", "source"]),
        ("4 relations, dont le compte joint C-1043 ↔ CPT-502 dĂ©couvert le 2 avril",
         lambda: couples() == [("C-1042", "CPT-501", "2026-01-05"), ("C-1042", "CPT-502", "2026-01-05"),
                               ("C-1043", "CPT-502", "2026-04-02"), ("C-1045", "CPT-503", "2026-01-05")]),
        ("chaque clé du link existe dans son hub",
         lambda: val("""SELECT COUNT(*) FROM link_client_compte l
                        WHERE l.hk_client NOT IN (SELECT hk_client FROM hub_client)
                           OR l.hk_compte NOT IN (SELECT hk_compte FROM hub_compte)""") == 0),
        ("rejouer les trois lots ne change rien", rejouer_tout),
    ]


def _exercice_6():
    return [
        ("empreinte('Kouassi Ama', 'Abidjan') = md5('KOUASSI AMA||ABIDJAN')",
         lambda: val("SELECT empreinte('Kouassi Ama', 'Abidjan')") == md5_py("KOUASSI AMA||ABIDJAN")),
        ("insensible aux espaces et Ă  la casse",
         lambda: val("SELECT empreinte('  kouassi ama ', 'ABIDJAN') = empreinte('Kouassi Ama', 'Abidjan')")),
        ("jamais NULL, mĂȘme si un attribut l'est",
         lambda: val("SELECT empreinte('+225 05 00 00 00 02', NULL) IS NOT NULL")),
        ("la position compte : empreinte('a', NULL) ≠ empreinte(NULL, 'a')",
         lambda: val("SELECT empreinte('a', NULL) <> empreinte(NULL, 'a')")),
        ("le sĂ©parateur est lĂ  : empreinte('ab', 'c') ≠ empreinte('a', 'bc')",
         lambda: val("SELECT empreinte('ab', 'c') <> empreinte('a', 'bc')")),
        ("accepte une date et un nombre",
         lambda: val("SELECT empreinte(DATE '2026-01-05', 3) IS NOT NULL")),
    ]


def _exercice_7():
    def rejouer_tout():
        avant = versions_agences()
        for lot in LOTS_AGENCES:
            charger_sat_client_agences(lot)
        return versions_agences() == avant

    return [
        ("colonnes : hk_client, date_chargement, hash_diff, nom, ville, source",
         lambda: colonnes("sat_client_agences") == ["hk_client", "date_chargement", "hash_diff", "nom", "ville", "source"]),
        ("clé primaire (hk_client, date_chargement)",
         lambda: val("""SELECT COUNT(*) FROM duckdb_constraints()
                        WHERE table_name = 'sat_client_agences' AND constraint_type = 'PRIMARY KEY'
                          AND constraint_column_names = ['hk_client', 'date_chargement']""") == 1),
        ("4 versions : le déménagement de C-1042 en ajoute une, les autres clients aucune",
         lambda: versions_agences() == VERSIONS_AGENCES),
        ("chaque hash_diff vaut empreinte(nom, ville)",
         lambda: val("SELECT COUNT(*) FROM sat_client_agences WHERE hash_diff <> empreinte(nom, ville)") == 0),
        ("rejouer les deux lots ne change rien", rejouer_tout),
    ]


def _exercice_8():
    def versions_mobile():
        return lignes("""SELECT h.numero_client, s.date_chargement::VARCHAR, s.telephone, s.email
                         FROM sat_client_mobile s JOIN hub_client h USING (hk_client) ORDER BY 1, 2""")

    def rejouer_tout():
        avant = versions_mobile()
        for lot in LOTS_MOBILE:
            charger_sat_client_mobile(lot)
        return versions_mobile() == avant

    return [
        ("colonnes : hk_client, date_chargement, hash_diff, telephone, email, source",
         lambda: colonnes("sat_client_mobile") == ["hk_client", "date_chargement", "hash_diff", "telephone", "email", "source"]),
        ("3 versions, une par client de l'appli (rattachées au hub)", lambda: len(versions_mobile()) == 3),
        ("aucune empreinte NULL (C-1043 n'a pas d'e-mail)",
         lambda: val("SELECT COUNT(*) FROM sat_client_mobile WHERE hash_diff IS NULL") == 0),
        ("hub_client n'a pas bougé", lambda: lignes("""SELECT numero_client, date_chargement::VARCHAR, source
                                                      FROM hub_client ORDER BY 1""") == HUB_CLIENTS),
        ("sat_client_agences n'a pas bougé", lambda: versions_agences() == VERSIONS_AGENCES),
        ("rejouer le lot ne change rien", rejouer_tout),
    ]


def _exercice_9():
    def roles():
        return lignes("""SELECT c.numero_client, k.numero_compte, s.date_chargement::VARCHAR, s.role
                         FROM sat_lien_client_compte s JOIN link_client_compte l USING (hk_lien)
                         JOIN hub_client c USING (hk_client) JOIN hub_compte k USING (hk_compte)
                         ORDER BY 1, 2, 3""")

    def rejouer_tout():
        avant = roles()
        for lot in LOTS_COMPTES:
            charger_sat_lien_client_compte(lot)
        return roles() == avant

    return [
        ("colonnes : hk_lien, date_chargement, hash_diff, role, source",
         lambda: colonnes("sat_lien_client_compte") == ["hk_lien", "date_chargement", "hash_diff", "role", "source"]),
        ("4 versions, dont C-1043 co-titulaire de CPT-502 depuis le 2 avril",
         lambda: roles() == [("C-1042", "CPT-501", "2026-01-05", "titulaire"),
                             ("C-1042", "CPT-502", "2026-01-05", "titulaire"),
                             ("C-1043", "CPT-502", "2026-04-02", "co-titulaire"),
                             ("C-1045", "CPT-503", "2026-01-05", "titulaire")]),
        ("chaque version est rattachée à une relation du link",
         lambda: val("""SELECT COUNT(*) FROM sat_lien_client_compte
                        WHERE hk_lien NOT IN (SELECT hk_lien FROM link_client_compte)""") == 0),
        ("chaque hash_diff vaut empreinte(role, NULL)",
         lambda: val("SELECT COUNT(*) FROM sat_lien_client_compte WHERE hash_diff <> empreinte(role, NULL)") == 0),
        ("rejouer les trois lots ne change rien", rejouer_tout),
    ]


def _exercice_10():
    def historique():
        return lignes("""SELECT c.numero_client, k.numero_compte, e.date_chargement::VARCHAR, e.actif
                         FROM sat_eff_client_compte e JOIN link_client_compte l USING (hk_lien)
                         JOIN hub_client c USING (hk_client) JOIN hub_compte k USING (hk_compte)
                         ORDER BY 1, 2, 3""")

    def actives_aujourdhui():
        return lignes("""SELECT c.numero_client, k.numero_compte
                         FROM (SELECT hk_lien, actif FROM sat_eff_client_compte
                               QUALIFY ROW_NUMBER() OVER (PARTITION BY hk_lien ORDER BY date_chargement DESC) = 1) e
                         JOIN link_client_compte l USING (hk_lien)
                         JOIN hub_client c USING (hk_client) JOIN hub_compte k USING (hk_compte)
                         WHERE e.actif ORDER BY 1, 2""")

    def rejouer_tout():
        avant = historique()
        for lot in LOTS_COMPTES:
            charger_sat_eff_client_compte(lot)
        return historique() == avant

    return [
        ("colonnes : hk_lien, date_chargement, actif, source",
         lambda: colonnes("sat_eff_client_compte") == ["hk_lien", "date_chargement", "actif", "source"]),
        ("C-1043 ↔ CPT-502 : actif le 2 avril, inactif le 1er septembre",
         lambda: [r for r in historique() if r[0] == "C-1043"] ==
                 [("C-1043", "CPT-502", "2026-04-02", True), ("C-1043", "CPT-502", "2026-09-01", False)]),
        ("5 lignes en tout : rien n'est réinséré quand le statut ne change pas", lambda: len(historique()) == 5),
        ("3 relations actives aujourd'hui",
         lambda: actives_aujourdhui() == [("C-1042", "CPT-501"), ("C-1042", "CPT-502"), ("C-1045", "CPT-503")]),
        ("le link a gardé ses 4 relations : rien n'a été supprimé",
         lambda: val("SELECT COUNT(*) FROM link_client_compte") == 4),
        ("rejouer les trois lots ne change rien", rejouer_tout),
    ]


def _exercice_11():
    def etat(d):
        return lignes(f"SELECT * FROM etat_clients_au(DATE '{d}') ORDER BY numero_client")

    return [
        ("colonnes : numero_client, nom, ville, telephone, email",
         lambda: [c[0] for c in con.sql("SELECT * FROM etat_clients_au(DATE '2026-03-01')").description] ==
                 ["numero_client", "nom", "ville", "telephone", "email"]),
        ("au 1er mars : 4 clients, Kouassi Ama Ă  Abidjan, C-1044 connu par l'appli seulement",
         lambda: etat("2026-03-01") == ETAT_1ER_MARS),
        ("au 1er juillet : Kouassi Ama est à Bouaké",
         lambda: [r[2] for r in etat("2026-07-01") if r[0] == "C-1042"] == ["Bouaké"]),
        ("au 10 janvier : C-1044 n'est pas encore connu, et aucun téléphone n'est arrivé",
         lambda: [r[0] for r in etat("2026-01-10")] == ["C-1042", "C-1043", "C-1045"]
                 and all(r[3] is None for r in etat("2026-01-10"))),
    ]


def _exercice_12():
    def via_pit(d):
        return lignes(f"""SELECT h.numero_client, a.nom, a.ville, m.telephone, m.email
                          FROM pit_client p JOIN hub_client h USING (hk_client)
                          LEFT JOIN sat_client_agences a
                                 ON a.hk_client = p.hk_client AND a.date_chargement = p.date_agences
                          LEFT JOIN sat_client_mobile m
                                 ON m.hk_client = p.hk_client AND m.date_chargement = p.date_mobile
                          WHERE p.date_snapshot = DATE '{d}' ORDER BY 1""")

    def pareil_partout():
        for (d,) in lignes("SELECT date_snapshot::VARCHAR FROM snapshots"):
            if via_pit(d) != lignes(f"SELECT * FROM etat_clients_au(DATE '{d}') ORDER BY 1"):
                return False
        return True

    return [
        ("colonnes : hk_client, date_snapshot, date_agences, date_mobile",
         lambda: colonnes("pit_client") == ["hk_client", "date_snapshot", "date_agences", "date_mobile"]),
        ("35 lignes : chaque client, Ă  chaque date oĂč il est dĂ©jĂ  connu",
         lambda: val("SELECT COUNT(*) FROM pit_client") == 35),
        ("C-1042 au 1er juillet : version des agences du 18 juin, version mobile du 11 février",
         lambda: lignes("""SELECT p.date_agences::VARCHAR, p.date_mobile::VARCHAR FROM pit_client p
                           JOIN hub_client h USING (hk_client)
                           WHERE h.numero_client = 'C-1042' AND p.date_snapshot = DATE '2026-07-01'""") ==
                 [("2026-06-18", "2026-02-11")]),
        ("C-1045 au 1er juillet : aucune version mobile (NULL)",
         lambda: val("""SELECT p.date_mobile IS NULL FROM pit_client p JOIN hub_client h USING (hk_client)
                        WHERE h.numero_client = 'C-1045' AND p.date_snapshot = DATE '2026-07-01'""")),
        ("Ă  chaque date, la PIT donne le mĂȘme Ă©tat que ta macro etat_clients_au (exercice 11)", pareil_partout),
    ]


def _exercice_13():
    return [
        ("colonnes : numero_client, nom, ville, telephone, email, nb_comptes_actifs",
         lambda: colonnes("dim_client") ==
                 ["numero_client", "nom", "ville", "telephone", "email", "nb_comptes_actifs"]),
        ("une ligne par client (4)",
         lambda: val("SELECT COUNT(*) FROM dim_client") == 4
                 and val("SELECT COUNT(DISTINCT numero_client) FROM dim_client") == 4),
        ("Kouassi Ama : à Bouaké, avec son téléphone, et 2 comptes actifs",
         lambda: lignes("SELECT ville, telephone, nb_comptes_actifs FROM dim_client WHERE numero_client = 'C-1042'") ==
                 [("Bouaké", "+225 07 00 00 00 01", 2)]),
        ("C-1043 a quitté le compte joint : 0 compte actif",
         lambda: val("SELECT nb_comptes_actifs FROM dim_client WHERE numero_client = 'C-1043'") == 0),
        ("C-1044, connu par l'appli seulement : pas de nom, 0 compte",
         lambda: lignes("SELECT nom, nb_comptes_actifs FROM dim_client WHERE numero_client = 'C-1044'") == [(None, 0)]),
    ]


def _exercice_14():
    def historique():
        return lignes("""SELECT numero_client, ville, valide_du::VARCHAR, valide_au::VARCHAR, est_courant
                         FROM dim_client_historique ORDER BY numero_client, valide_du""")

    return [
        ("colonnes : numero_client, nom, ville, valide_du, valide_au, est_courant",
         lambda: colonnes("dim_client_historique") ==
                 ["numero_client", "nom", "ville", "valide_du", "valide_au", "est_courant"]),
        ("C-1042 : Abidjan jusqu'au 18 juin, puis Bouaké (version courante)",
         lambda: [r for r in historique() if r[0] == "C-1042"] ==
                 [("C-1042", "Abidjan", "2026-01-05", "2026-06-18", False),
                  ("C-1042", "Bouaké", "2026-06-18", None, True)]),
        ("une seule version courante par client",
         lambda: val("""SELECT COUNT(*) FROM (SELECT numero_client FROM dim_client_historique
                        WHERE est_courant GROUP BY 1 HAVING COUNT(*) <> 1)""") == 0
                 and val("SELECT COUNT(*) FROM dim_client_historique WHERE est_courant") == 3),
        ("une version courante n'a pas de fin, une ancienne en a une",
         lambda: val("""SELECT COUNT(*) FROM dim_client_historique
                        WHERE est_courant <> (valide_au IS NULL)""") == 0),
    ]


def verifier_exercice(n):
    verifier(f"Exercice {n}", globals()[f"_exercice_{n}"]())


print("✅ VĂ©rifications chargĂ©es")

3. Le hub — l'identitĂ©, et rien d'autre

3.1 Ce que contient un hub

Un hub est la liste des clés métier d'un concept : tous les clients que l'entrepÎt a vus passer, une ligne chacun, pour toujours.

Colonne RĂŽle
hk_client ClĂ© de hachage de la clĂ© mĂ©tier — la clĂ© primaire
numero_client Clé métier : l'identifiant que tout le monde utilise vraiment (pas un id technique d'une base source)
date_chargement Le lot qui a fait découvrir ce client à l'entrepÎt
source Le systĂšme qui l'a fourni en premier

Pas de nom, pas de ville, pas de téléphone : tout cela change, et ce qui change va dans les satellites.

💡 Choisir la clĂ© mĂ©tier est la dĂ©cision la plus importante du modĂšle. Elle doit ĂȘtre stable et partagĂ©e entre les sources. Ici, toutes les sources connaissent le numĂ©ro client C-xxxx : c'est lui.

Exercice 1 : Créer la table hub_client

Objectif : crĂ©er la table avec les 4 colonnes ci-dessus. hk_client est la clĂ© primaire ; aucune colonne ne peut ĂȘtre NULL.

Types conseillés : VARCHAR pour les clés et la source, DATE pour la date de chargement.

pythonVoir le code
executer("""
-- À TOI : CREATE TABLE hub_client ( ... )
""")
pythonVoir le code
verifier_exercice(1)
💡 Solution
executer("""
CREATE OR REPLACE TABLE hub_client (
    hk_client       VARCHAR PRIMARY KEY,   -- clé de hachage
    numero_client   VARCHAR NOT NULL,      -- clé métier
    date_chargement DATE    NOT NULL,      -- lot qui a fait découvrir le client
    source          VARCHAR NOT NULL       -- systĂšme d'origine
)
""")

3.2 Pourquoi une clé de hachage ?

On pourrait numéroter les clients avec une séquence (1, 2, 3
). Mais alors, pour charger un satellite ou un link, il faudrait d'abord demander au hub quel numéro il a attribué au client : les chargements deviendraient dépendants les uns des autres.

Avec une clĂ© de hachage, hk_client = MD5(clĂ© mĂ©tier) se calcule partout, sans rien demander Ă  personne. Le hub, les links et les satellites peuvent se charger en parallĂšle, et deux environnements calculent toujours la mĂȘme clĂ©.

â„č MD5 est utilisĂ© ici comme identifiant, pas pour la sĂ©curitĂ©. C'est le choix historique de Data Vault 2.0 ; certaines Ă©quipes prĂ©fĂšrent SHA-1 ou SHA-256. Le principe est le mĂȘme.

3.3 Le piĂšge : normaliser avant de hacher

Un hachage est impitoyable : un seul caractÚre de différence donne une clé complÚtement différente. Regarde ce que l'application mobile a livré pour Kouassi Ama :

pythonVoir le code
voir("""
SELECT 'C-1042'  AS saisie, md5('C-1042')  AS hk UNION ALL
SELECT ' c-1042',           md5(' c-1042')
""")

Deux clĂ©s, donc deux clients dans le hub, alors que c'est la mĂȘme personne. La parade : appliquer des rĂšgles de normalisation dans le staging, avant de hacher — au minimum TRIM (espaces) et UPPER (casse).

Ces rÚgles « dures » ne changent pas le sens de la donnée : elles corrigent sa forme. Les vraies rÚgles métier (fusionner deux clients, recalculer un statut) viennent plus tard, au-dessus du Data Vault.

Exercice 2 : Préparer le staging

Objectif : créer deux vues qui préparent les livraisons pour le chargement.

  • v_stg_agences_clients, Ă  partir de stg_agences_clients
  • v_stg_mobile_clients, Ă  partir de stg_mobile_clients

Chaque vue doit exposer, dans cet ordre :

Colonne Contenu
hk_client md5 de la clé métier normalisée
numero_client la clé métier normalisée (UPPER(TRIM(...)))
les attributs nom, ville pour les agences · telephone, email pour le mobile
date_chargement inchangée
source le texte 'agences' ou 'mobile'
pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE VIEW v_stg_agences_clients AS SELECT ... ;
-- À TOI : CREATE OR REPLACE VIEW v_stg_mobile_clients  AS SELECT ... ;
""")
pythonVoir le code
verifier_exercice(2)
💡 Solution
executer("""
CREATE OR REPLACE VIEW v_stg_agences_clients AS
SELECT
    md5(UPPER(TRIM(numero_client))) AS hk_client,
    UPPER(TRIM(numero_client))      AS numero_client,
    nom,
    ville,
    date_chargement,
    'agences'                       AS source
FROM stg_agences_clients;

CREATE OR REPLACE VIEW v_stg_mobile_clients AS
SELECT
    md5(UPPER(TRIM(numero_client))) AS hk_client,
    UPPER(TRIM(numero_client))      AS numero_client,
    telephone,
    email,
    date_chargement,
    'mobile'                        AS source
FROM stg_mobile_clients;
""")

3.4 Charger un hub : insérer seulement l'inconnu

La rĂšgle de chargement d'un hub tient en une ligne : on insĂšre les clĂ©s qu'on ne connaĂźt pas encore. Une clĂ© dĂ©jĂ  prĂ©sente ne bouge pas — mĂȘme si une autre source la livre plus tard, la ligne garde la date et la source de sa premiĂšre apparition.

lot du 5 janvier (agences)   C-1042  C-1043  C-1045         → 3 insertions
lot du 11 fĂ©vrier (mobile)   C-1042  C-1043  C-1044         → 1 insertion (C-1044)
lot du 18 juin (agences)     C-1042  C-1043  C-1045         → 0 insertion

Conséquence précieuse : on peut rejouer un chargement autant de fois qu'on veut, le résultat ne change pas. Un job qui plante au milieu se relance sans réfléchir.

Exercice 3 : Le chargement du hub

Objectif : compléter charger_hub_client(vue, lot). Elle insÚre dans hub_client les clients de la vue vue pour le lot lot, uniquement s'ils sont absents du hub.

La cellule suivante rejoue ensuite les trois lots dans l'ordre chronologique.

💡 Indice : INSERT INTO ... SELECT DISTINCT ... WHERE NOT EXISTS (SELECT 1 FROM hub_client h WHERE ...)

⚠ Si DuckDB rĂ©pond Duplicate key ... violates primary key constraint, tu essaies d'insĂ©rer une clĂ© dĂ©jĂ  prĂ©sente : c'est prĂ©cisĂ©ment ce que ton chargement doit Ă©viter. RecrĂ©e la table (relance l'exercice 1) avant de rĂ©essayer.

pythonVoir le code
def charger_hub_client(vue, lot):
    executer(f"""
    -- À TOI : INSERT INTO hub_client SELECT ...
    --   depuis {vue}, pour le lot DATE '{lot}',
    --   uniquement les clés absentes du hub
    """)


LOTS_CLIENTS = [
    ("v_stg_agences_clients", "2026-01-05"),
    ("v_stg_mobile_clients",  "2026-02-11"),
    ("v_stg_agences_clients", "2026-06-18"),
]
for vue, lot in LOTS_CLIENTS:
    charger_hub_client(vue, lot)

voir("SELECT * FROM hub_client ORDER BY numero_client")
pythonVoir le code
verifier_exercice(3)
💡 Solution
def charger_hub_client(vue, lot):
    executer(f"""
    INSERT INTO hub_client
    SELECT DISTINCT s.hk_client, s.numero_client, s.date_chargement, s.source
    FROM {vue} s
    WHERE s.date_chargement = DATE '{lot}'
      AND NOT EXISTS (
          SELECT 1 FROM hub_client h
          WHERE h.hk_client = s.hk_client
      )
    """)


LOTS_CLIENTS = [
    ("v_stg_agences_clients", "2026-01-05"),
    ("v_stg_mobile_clients",  "2026-02-11"),
    ("v_stg_agences_clients", "2026-06-18"),
]
for vue, lot in LOTS_CLIENTS:
    charger_hub_client(vue, lot)

voir("SELECT * FROM hub_client ORDER BY numero_client")

🧠 À retenir sur les hubs

  • Un hub ne contient que l'identitĂ© : clĂ© de hachage, clĂ© mĂ©tier, date, source.
  • La clĂ© de hachage se calcule partout Ă  partir de la clĂ© mĂ©tier — Ă  condition de normaliser d'abord.
  • On n'insĂšre que l'inconnu : le chargement est rejouable Ă  volontĂ©.

Prochaine étape : relier les clients à leurs comptes avec un link.


Un link enregistre qu'une relation a existé entre plusieurs clés métier : ici, qu'un client détient un compte. Il ne contient que des clés :

Colonne RĂŽle
hk_lien ClĂ© de hachage de la combinaison des clĂ©s mĂ©tier — la clĂ© primaire
hk_client La clé du hub client
hk_compte La clé du hub compte
date_chargement Le lot qui a fait découvrir la relation
source Le systĂšme qui l'a fournie

Trois rĂšgles d'assemblage valent pour tout le modĂšle :

  • un hub ne pointe vers rien ;
  • un link relie deux hubs ou plus — jamais un satellite — et ne porte aucun attribut descriptif ;
  • un satellite s'accroche Ă  un seul parent : un hub ou un link.

💡 Un link est toujours de plusieurs Ă  plusieurs. MĂȘme si aujourd'hui chaque compte n'a qu'un titulaire, le modĂšle ne l'impose pas. Le jour oĂč la banque ouvre des comptes joints, rien ne change : il y a juste des lignes en plus.

hk_lien est le hachage des clĂ©s mĂ©tier mises bout Ă  bout, toujours dans le mĂȘme ordre. Mais attention Ă  la façon de les coller :

pythonVoir le code
voir("""
SELECT md5('AB' || 'C')         = md5('A' || 'BC')         AS sans_separateur_meme_cle,
       md5('AB' || '||' || 'C') = md5('A' || '||' || 'BC') AS avec_separateur_meme_cle
""")

Sans sĂ©parateur, deux couples diffĂ©rents donnent la mĂȘme clĂ©. On place donc toujours un sĂ©parateur entre les morceaux — ici '||' — et on normalise chaque morceau d'abord :

md5(UPPER(TRIM(numero_client)) || '||' || UPPER(TRIM(numero_compte)))

Exercice 4 : Le staging des comptes

Objectif : créer la vue v_stg_agences_comptes à partir de stg_agences_comptes. Elle prépare trois clés d'un coup (séparateur : '||'), dans cet ordre :

Colonne Contenu
hk_lien md5 du numéro client et du numéro de compte normalisés, avec le séparateur (client d'abord)
hk_client comme dans v_stg_agences_clients
hk_compte md5 du numéro de compte normalisé
numero_client, numero_compte normalisés
type_compte, statut, role inchangés
date_chargement inchangée
source le texte 'agences'
pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE VIEW v_stg_agences_comptes AS SELECT ...
""")
pythonVoir le code
verifier_exercice(4)
💡 Solution
executer("""
CREATE OR REPLACE VIEW v_stg_agences_comptes AS
SELECT
    md5(UPPER(TRIM(numero_client)) || '||' || UPPER(TRIM(numero_compte))) AS hk_lien,
    md5(UPPER(TRIM(numero_client)))  AS hk_client,
    md5(UPPER(TRIM(numero_compte)))  AS hk_compte,
    UPPER(TRIM(numero_client))       AS numero_client,
    UPPER(TRIM(numero_compte))       AS numero_compte,
    type_compte,
    statut,
    role,
    date_chargement,
    'agences'                        AS source
FROM stg_agences_comptes
""")

Objectif :

  1. crĂ©er hub_compte — mĂȘme recette que hub_client, avec hk_compte et numero_compte ;
  2. créer link_client_compte avec les 5 colonnes du §4.1 (clé primaire hk_lien, aucune colonne NULL) ;
  3. compléter charger_hub_compte(lot) et charger_link_client_compte(lot), qui n'insÚrent que l'inconnu.

La boucle rejoue ensuite les trois lots des comptes : 5 janvier, 2 avril (le compte joint), 1er septembre.

💡 Dans le lot du 2 avril, CPT-502 apparaĂźt deux fois (deux titulaires). Ton chargement du hub ne doit l'insĂ©rer qu'une fois.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE TABLE hub_compte (...)
--         (mĂȘme recette que hub_client)
-- À TOI : CREATE OR REPLACE TABLE link_client_compte (...)
--         (5 colonnes, que des clés)
""")


def charger_hub_compte(lot):
    executer(f"""
    -- À TOI : les comptes absents de hub_compte, pour le lot DATE '{lot}'
    """)


def charger_link_client_compte(lot):
    executer(f"""
    -- À TOI : les couples (client, compte) absents du link,
    --         pour le lot DATE '{lot}'
    """)


LOTS_COMPTES = ["2026-01-05", "2026-04-02", "2026-09-01"]
for lot in LOTS_COMPTES:
    charger_hub_compte(lot)
    charger_link_client_compte(lot)

voir("""
SELECT c.numero_client, k.numero_compte, l.date_chargement, l.source
FROM link_client_compte l
JOIN hub_client c USING (hk_client)
JOIN hub_compte k USING (hk_compte)
ORDER BY 1, 2
""")
pythonVoir le code
verifier_exercice(5)
💡 Solution
executer("""
CREATE OR REPLACE TABLE hub_compte (
    hk_compte       VARCHAR PRIMARY KEY,
    numero_compte   VARCHAR NOT NULL,
    date_chargement DATE    NOT NULL,
    source          VARCHAR NOT NULL
);
CREATE OR REPLACE TABLE link_client_compte (
    hk_lien         VARCHAR PRIMARY KEY,   -- hachage du couple (client, compte)
    hk_client       VARCHAR NOT NULL,
    hk_compte       VARCHAR NOT NULL,
    date_chargement DATE    NOT NULL,
    source          VARCHAR NOT NULL
)
""")


def charger_hub_compte(lot):
    executer(f"""
    INSERT INTO hub_compte
    SELECT DISTINCT s.hk_compte, s.numero_compte, s.date_chargement, s.source
    FROM v_stg_agences_comptes s
    WHERE s.date_chargement = DATE '{lot}'
      AND NOT EXISTS (SELECT 1 FROM hub_compte h WHERE h.hk_compte = s.hk_compte)
    """)


def charger_link_client_compte(lot):
    executer(f"""
    INSERT INTO link_client_compte
    SELECT DISTINCT s.hk_lien, s.hk_client, s.hk_compte, s.date_chargement, s.source
    FROM v_stg_agences_comptes s
    WHERE s.date_chargement = DATE '{lot}'
      AND NOT EXISTS (SELECT 1 FROM link_client_compte l WHERE l.hk_lien = s.hk_lien)
    """)


LOTS_COMPTES = ["2026-01-05", "2026-04-02", "2026-09-01"]
for lot in LOTS_COMPTES:
    charger_hub_compte(lot)
    charger_link_client_compte(lot)

voir("""
SELECT c.numero_client, k.numero_compte, l.date_chargement, l.source
FROM link_client_compte l
JOIN hub_client c USING (hk_client)
JOIN hub_compte k USING (hk_compte)
ORDER BY 1, 2
""")

🧠 À retenir sur les links

  • Un link ne contient que des clĂ©s : la sienne (hachage de la combinaison) et celles des hubs qu'il relie.
  • Toujours un sĂ©parateur entre les morceaux de la clĂ©, et toujours le mĂȘme ordre.
  • Il est de plusieurs Ă  plusieurs : un compte joint, c'est une ligne de plus, pas un nouveau modĂšle.
  • Remarque : le lot du 1er septembre n'a rien retirĂ© du link, alors que C-1043 n'y figure plus. Un link dit qu'une relation a existĂ©. Savoir si elle existe encore, c'est le rĂŽle du §6.

5. Les satellites — la description, et son histoire

5.1 Ce que contient un satellite

Colonne RĂŽle
hk_client La clé du parent (ici le hub client)
date_chargement Le lot de cette version — avec hk_client, c'est la clĂ© primaire
hash_diff L'empreinte des attributs : elle sert à savoir si quelque chose a changé
nom, ville Les attributs descriptifs
source Le systĂšme d'origine

Une ligne = une version de la description. Quand la ville change, on n'écrase rien : on ajoute une version.

On fait un satellite par source (sat_client_agences, sat_client_mobile) : chaque systÚme garde sa version des faits, et en brancher un nouveau ne touche à rien d'existant. On sépare aussi parfois les attributs selon leur rythme de changement (un solde qui bouge tous les jours n'a rien à faire avec une date de naissance).

5.2 L'empreinte, et le piĂšge des NULL

Pour savoir si une version a changé, on compare une seule valeur au lieu de tous les attributs : hash_diff, le hachage des attributs mis bout à bout. Deux façons naïves de les coller cassent en silence :

pythonVoir le code
voir("""
SELECT
    -- avec || : un seul NULL et toute l'empreinte devient NULL
    'Abidjan' || NULL                  AS avec_barres,
    -- avec concat_ws : deux lignes diffĂ©rentes donnent le mĂȘme texte
    concat_ws('||', 'a', NULL, 'b')    AS concat_ws_1,
    concat_ws('||', 'a', 'b', NULL)    AS concat_ws_2
""")

La parade : remplacer chaque NULL par une chaßne vide avant de coller, et garder le séparateur. On normalise aussi chaque morceau (TRIM, UPPER) : une majuscule corrigée n'est pas un vrai changement.

Exercice 6 : La macro empreinte(a, b)

Objectif : Ă©crire une macro DuckDB — une fonction SQL rĂ©utilisable — qui calcule l'empreinte de deux attributs :

CREATE OR REPLACE MACRO empreinte(a, b) AS <ton expression>;

Elle renvoie le md5 de a et b normalisĂ©s (TRIM, UPPER), sĂ©parĂ©s par '||', oĂč chaque NULL devient ''.

💡 Ajoute CAST(... AS VARCHAR) autour de chaque attribut : la macro acceptera aussi des dates et des nombres.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE MACRO empreinte(a, b) AS ...
""")
pythonVoir le code
verifier_exercice(6)
💡 Solution
executer("""
CREATE OR REPLACE MACRO empreinte(a, b) AS
    md5(
        UPPER(TRIM(COALESCE(CAST(a AS VARCHAR), '')))
        || '||' ||
        UPPER(TRIM(COALESCE(CAST(b AS VARCHAR), '')))
    )
""")

5.3 Charger un satellite : insérer seulement ce qui a changé

Pour chaque client du lot, on compare son empreinte Ă  celle de sa derniĂšre version connue :

Client · lot DerniÚre version connue Dans le lot Action
C-1042 · 5 janvier (aucune) Abidjan ➕ insĂ©rer
C-1042 · 18 juin Abidjan BouakĂ© ➕ insĂ©rer (il a dĂ©mĂ©nagĂ©)
C-1043 · 18 juin Yamoussoukro Yamoussoukro rien

Exercice 7 : Le satellite des agences

Objectif :

  1. crĂ©er sat_client_agences avec les colonnes hk_client, date_chargement, hash_diff, nom, ville, source — clĂ© primaire composĂ©e (hk_client, date_chargement) ;
  2. compléter charger_sat_client_agences(lot) : elle insÚre les clients du lot dont l'empreinte (empreinte(nom, ville)) diffÚre de leur derniÚre version connue, ou qui n'en ont pas encore.

💡 La derniùre version de chaque client : QUALIFY ROW_NUMBER() OVER (PARTITION BY hk_client ORDER BY date_chargement DESC) = 1

⚠ Compare Ă  la derniĂšre version connue Ă  la date du lot (date_chargement <= lot), pas Ă  la toute derniĂšre. Sinon, rejouer le lot de janvier aprĂšs celui de juin comparerait Abidjan Ă  Bouaké  et tenterait de rĂ©insĂ©rer une version qui existe dĂ©jĂ .

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE TABLE sat_client_agences (...)
--         clé primaire : PRIMARY KEY (hk_client, date_chargement)
""")


def charger_sat_client_agences(lot):
    executer(f"""
    -- À TOI : INSERT INTO sat_client_agences
    --   les clients de v_stg_agences_clients pour le lot DATE '{lot}'
    --   dont l'empreinte diffĂšre de leur derniĂšre version connue Ă  cette date
    """)


LOTS_AGENCES = ["2026-01-05", "2026-06-18"]
for lot in LOTS_AGENCES:
    charger_sat_client_agences(lot)

voir("""
SELECT h.numero_client, s.date_chargement, s.nom, s.ville, s.hash_diff
FROM sat_client_agences s JOIN hub_client h USING (hk_client)
ORDER BY 1, 2
""")
pythonVoir le code
verifier_exercice(7)
💡 Solution
executer("""
CREATE OR REPLACE TABLE sat_client_agences (
    hk_client       VARCHAR NOT NULL,
    date_chargement DATE    NOT NULL,
    hash_diff       VARCHAR NOT NULL,
    nom             VARCHAR,
    ville           VARCHAR,
    source          VARCHAR NOT NULL,
    PRIMARY KEY (hk_client, date_chargement)
)
""")


def charger_sat_client_agences(lot):
    executer(f"""
    INSERT INTO sat_client_agences
    SELECT s.hk_client, s.date_chargement, empreinte(s.nom, s.ville), s.nom, s.ville, s.source
    FROM v_stg_agences_clients s
    LEFT JOIN (
        -- la derniĂšre version connue de chaque client, Ă  la date du lot
        SELECT hk_client, hash_diff
        FROM sat_client_agences
        WHERE date_chargement <= DATE '{lot}'
        QUALIFY ROW_NUMBER() OVER (PARTITION BY hk_client ORDER BY date_chargement DESC) = 1
    ) derniere USING (hk_client)
    WHERE s.date_chargement = DATE '{lot}'
      AND (derniere.hash_diff IS NULL OR derniere.hash_diff <> empreinte(s.nom, s.ville))
    """)


LOTS_AGENCES = ["2026-01-05", "2026-06-18"]
for lot in LOTS_AGENCES:
    charger_sat_client_agences(lot)

voir("""
SELECT h.numero_client, s.date_chargement, s.nom, s.ville, s.hash_diff
FROM sat_client_agences s JOIN hub_client h USING (hk_client)
ORDER BY 1, 2
""")

Exercice 8 : Brancher une nouvelle source

C'est le moment de vérité de la méthode. L'application mobile livre des attributs que les agences n'ont pas : le téléphone et l'e-mail.

Objectif : crĂ©er sat_client_mobile (hk_client, date_chargement, hash_diff, telephone, email, source) et son chargement charger_sat_client_mobile(lot), sans modifier aucune table existante. MĂȘme recette que l'exercice 7, sur v_stg_mobile_clients.

La vérification contrÎle aussi que le hub et le satellite des agences n'ont pas bougé d'une ligne.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE TABLE sat_client_mobile (...)
""")


def charger_sat_client_mobile(lot):
    executer(f"""
    -- À TOI : mĂȘme recette que charger_sat_client_agences,
    --         sur v_stg_mobile_clients
    """)


LOTS_MOBILE = ["2026-02-11"]
for lot in LOTS_MOBILE:
    charger_sat_client_mobile(lot)

voir("""
SELECT h.numero_client, s.date_chargement, s.telephone, s.email, s.hash_diff
FROM sat_client_mobile s JOIN hub_client h USING (hk_client)
ORDER BY 1
""")
pythonVoir le code
verifier_exercice(8)
💡 Solution
executer("""
CREATE OR REPLACE TABLE sat_client_mobile (
    hk_client       VARCHAR NOT NULL,
    date_chargement DATE    NOT NULL,
    hash_diff       VARCHAR NOT NULL,
    telephone       VARCHAR,
    email           VARCHAR,
    source          VARCHAR NOT NULL,
    PRIMARY KEY (hk_client, date_chargement)
)
""")


def charger_sat_client_mobile(lot):
    executer(f"""
    INSERT INTO sat_client_mobile
    SELECT s.hk_client, s.date_chargement, empreinte(s.telephone, s.email), s.telephone, s.email, s.source
    FROM v_stg_mobile_clients s
    LEFT JOIN (
        SELECT hk_client, hash_diff
        FROM sat_client_mobile
        WHERE date_chargement <= DATE '{lot}'
        QUALIFY ROW_NUMBER() OVER (PARTITION BY hk_client ORDER BY date_chargement DESC) = 1
    ) derniere USING (hk_client)
    WHERE s.date_chargement = DATE '{lot}'
      AND (derniere.hash_diff IS NULL OR derniere.hash_diff <> empreinte(s.telephone, s.email))
    """)


LOTS_MOBILE = ["2026-02-11"]
for lot in LOTS_MOBILE:
    charger_sat_client_mobile(lot)

voir("""
SELECT h.numero_client, s.date_chargement, s.telephone, s.email, s.hash_diff
FROM sat_client_mobile s JOIN hub_client h USING (hk_client)
ORDER BY 1
""")

5.4 Le satellite des comptes (fourni)

Pour complĂ©ter le modĂšle, voici le satellite des comptes : exactement la mĂȘme recette, appliquĂ©e au hub hub_compte. Une seule nuance : un compte joint apparaĂźt sur plusieurs lignes du staging, d'oĂč le DISTINCT.

pythonVoir le code
con.execute("""
CREATE OR REPLACE TABLE sat_compte (
    hk_compte       VARCHAR NOT NULL,
    date_chargement DATE    NOT NULL,
    hash_diff       VARCHAR NOT NULL,
    type_compte     VARCHAR,
    statut          VARCHAR,
    source          VARCHAR NOT NULL,
    PRIMARY KEY (hk_compte, date_chargement)
)
""")


def charger_sat_compte(lot):
    con.execute(f"""
    INSERT INTO sat_compte
    SELECT s.hk_compte, s.date_chargement, empreinte(s.type_compte, s.statut),
           s.type_compte, s.statut, s.source
    FROM (SELECT DISTINCT hk_compte, date_chargement, type_compte, statut, source
          FROM v_stg_agences_comptes) s
    LEFT JOIN (
        SELECT hk_compte, hash_diff
        FROM sat_compte
        WHERE date_chargement <= DATE '{lot}'
        QUALIFY ROW_NUMBER() OVER (
            PARTITION BY hk_compte ORDER BY date_chargement DESC) = 1
    ) derniere USING (hk_compte)
    WHERE s.date_chargement = DATE '{lot}'
      AND (derniere.hash_diff IS NULL
           OR derniere.hash_diff <> empreinte(s.type_compte, s.statut))
    """)


try:
    for lot in LOTS_COMPTES:
        charger_sat_compte(lot)
except duckdb.Error:
    print("⏳ Cette cellule s'appuie sur les exercices 4 à 6 :\n"
          "   termine-les, puis relance-la.")

voir("""
SELECT k.numero_compte, s.* EXCLUDE (hk_compte)
FROM sat_compte s JOIN hub_compte k USING (hk_compte)
ORDER BY 1
""")

🧠 À retenir sur les satellites

  • Une ligne = une version de la description ; la clĂ© primaire est (clĂ© du parent, date_chargement).
  • On n'insĂšre que si l'empreinte a changĂ© — en la comparant Ă  la derniĂšre version connue Ă  la date du lot.
  • L'empreinte se calcule sur des valeurs normalisĂ©es, avec un sĂ©parateur, et sans laisser passer de NULL.
  • Un satellite par source : brancher une source, c'est ajouter des tables, jamais en modifier.

6. Décrire et dater une relation

Le rĂŽle d'une personne dans un compte — titulaire ou co-titulaire — ne dĂ©crit ni le client (on peut ĂȘtre titulaire d'un compte et co-titulaire d'un autre), ni le compte. Il dĂ©crit la relation. Sa place est donc dans un satellite accrochĂ© au link, avec hk_lien comme clĂ© du parent.

   hub_client ─── link_client_compte ─── hub_compte
                          │
                 sat_lien_client_compte   (role)

Objectif : créer sat_lien_client_compte (hk_lien, date_chargement, hash_diff, role, source, clé primaire (hk_lien, date_chargement)) et compléter charger_sat_lien_client_compte(lot), sur v_stg_agences_comptes.

Un seul attribut ici : calcule l'empreinte avec empreinte(role, NULL).

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE TABLE sat_lien_client_compte (...)
""")


def charger_sat_lien_client_compte(lot):
    executer(f"""
    -- À TOI : mĂȘme recette que les satellites de hub,
    --         avec hk_lien comme clé du parent
    """)


for lot in LOTS_COMPTES:
    charger_sat_lien_client_compte(lot)

voir("""
SELECT c.numero_client, k.numero_compte, s.date_chargement, s.role
FROM sat_lien_client_compte s
JOIN link_client_compte l USING (hk_lien)
JOIN hub_client c USING (hk_client)
JOIN hub_compte k USING (hk_compte)
ORDER BY 1, 2
""")
pythonVoir le code
verifier_exercice(9)
💡 Solution
executer("""
CREATE OR REPLACE TABLE sat_lien_client_compte (
    hk_lien         VARCHAR NOT NULL,
    date_chargement DATE    NOT NULL,
    hash_diff       VARCHAR NOT NULL,
    role            VARCHAR,
    source          VARCHAR NOT NULL,
    PRIMARY KEY (hk_lien, date_chargement)
)
""")


def charger_sat_lien_client_compte(lot):
    executer(f"""
    INSERT INTO sat_lien_client_compte
    SELECT s.hk_lien, s.date_chargement, empreinte(s.role, NULL), s.role, s.source
    FROM v_stg_agences_comptes s
    LEFT JOIN (
        SELECT hk_lien, hash_diff
        FROM sat_lien_client_compte
        WHERE date_chargement <= DATE '{lot}'
        QUALIFY ROW_NUMBER() OVER (PARTITION BY hk_lien ORDER BY date_chargement DESC) = 1
    ) derniere USING (hk_lien)
    WHERE s.date_chargement = DATE '{lot}'
      AND (derniere.hash_diff IS NULL OR derniere.hash_diff <> empreinte(s.role, NULL))
    """)


for lot in LOTS_COMPTES:
    charger_sat_lien_client_compte(lot)

voir("""
SELECT c.numero_client, k.numero_compte, s.date_chargement, s.role
FROM sat_lien_client_compte s
JOIN link_client_compte l USING (hk_lien)
JOIN hub_client c USING (hk_client)
JOIN hub_compte k USING (hk_compte)
ORDER BY 1, 2
""")

6.2 Le satellite d'effectivité : une relation qui se termine

Le 1er septembre, C-1043 n'apparaßt plus comme co-titulaire de CPT-502 dans l'extraction des agences. Il a quitté le compte joint.

Pas question de DELETE dans le link : il dit qu'une relation a existé, et c'est toujours vrai. On ajoute un satellite d'effectivité qui enregistre, lot aprÚs lot, si la relation est active dans la source :

Lot C-1043 ↔ CPT-502 dans l'extraction ? AjoutĂ© Ă  sat_eff_client_compte
5 janvier relation pas encore connue —
2 avril oui (2026-04-02, actif = true)
1er septembre non (2026-09-01, actif = false)

Ce raisonnement suppose que la source livre une extraction complÚte à chaque lot : une relation absente est une relation terminée. Si la source ne livrait que les nouveautés, une absence ne voudrait rien dire.

Exercice 10 ⭐ (défi) : Le satellite d'effectivité

Objectif : créer sat_eff_client_compte (hk_lien, date_chargement, actif BOOLEAN, source, clé primaire (hk_lien, date_chargement)) et compléter charger_sat_eff_client_compte(lot).

Pour chaque relation du link déjà connue à la date du lot :

  1. actif vaut vrai si la relation figure dans le lot de v_stg_agences_comptes, faux sinon ;
  2. on insĂšre une ligne seulement si ce statut diffĂšre du dernier statut connu Ă  la date du lot (ou s'il n'y en a pas encore).

💡 EXISTS (SELECT 1 FROM ... WHERE ...) renvoie directement un boolĂ©en. Et a IS DISTINCT FROM b compare deux valeurs mĂȘme quand l'une est NULL.

⚠ « DĂ©jĂ  connue Ă  la date du lot » compte : le link contient dĂ©jĂ  le compte joint d'avril quand tu traites le lot de janvier. Sans ce filtre, janvier dĂ©clarerait inactive une relation qui n'existait pas encore.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE TABLE sat_eff_client_compte (...)
""")


def charger_sat_eff_client_compte(lot):
    executer(f"""
    -- À TOI : pour chaque relation du link connue au DATE '{lot}',
    --   actif = la relation est-elle dans le lot ?
    --   insérer seulement si ce statut diffÚre du dernier connu
    """)


for lot in LOTS_COMPTES:
    charger_sat_eff_client_compte(lot)

voir("""
SELECT c.numero_client, k.numero_compte, e.date_chargement, e.actif
FROM sat_eff_client_compte e
JOIN link_client_compte l USING (hk_lien)
JOIN hub_client c USING (hk_client)
JOIN hub_compte k USING (hk_compte)
ORDER BY 1, 2, 3
""")
pythonVoir le code
verifier_exercice(10)
💡 Solution
executer("""
CREATE OR REPLACE TABLE sat_eff_client_compte (
    hk_lien         VARCHAR NOT NULL,
    date_chargement DATE    NOT NULL,
    actif           BOOLEAN NOT NULL,
    source          VARCHAR NOT NULL,
    PRIMARY KEY (hk_lien, date_chargement)
)
""")


def charger_sat_eff_client_compte(lot):
    executer(f"""
    INSERT INTO sat_eff_client_compte
    WITH etat AS (
        -- le statut de chaque relation déjà connue, d'aprÚs le lot
        SELECT l.hk_lien,
               EXISTS (SELECT 1 FROM v_stg_agences_comptes s
                       WHERE s.hk_lien = l.hk_lien
                         AND s.date_chargement = DATE '{lot}') AS actif
        FROM link_client_compte l
        WHERE l.date_chargement <= DATE '{lot}'
    ),
    dernier AS (
        -- le dernier statut connu Ă  la date du lot
        SELECT hk_lien, actif
        FROM sat_eff_client_compte
        WHERE date_chargement <= DATE '{lot}'
        QUALIFY ROW_NUMBER() OVER (PARTITION BY hk_lien ORDER BY date_chargement DESC) = 1
    )
    SELECT e.hk_lien, DATE '{lot}', e.actif, 'agences'
    FROM etat e
    LEFT JOIN dernier d USING (hk_lien)
    WHERE d.actif IS DISTINCT FROM e.actif
    """)


for lot in LOTS_COMPTES:
    charger_sat_eff_client_compte(lot)

voir("""
SELECT c.numero_client, k.numero_compte, e.date_chargement, e.actif
FROM sat_eff_client_compte e
JOIN link_client_compte l USING (hk_lien)
JOIN hub_client c USING (hk_client)
JOIN hub_compte k USING (hk_compte)
ORDER BY 1, 2, 3
""")

6.3 Les autres variantes, en bref

Tu connais maintenant les trois briques et deux variantes. Les autres que tu croiseras ne sont, elles aussi, que des cas particuliers :

Variante Quand on s'en sert
Satellite multi-actif Plusieurs valeurs valides en mĂȘme temps pour une mĂȘme clĂ© (plusieurs tĂ©lĂ©phones)
Same-as link Deux clĂ©s du mĂȘme hub dĂ©signent la mĂȘme chose (un doublon entre sources)
Link hiĂ©rarchique Un hub reliĂ© Ă  lui-mĂȘme (un client parrain d'un autre client)
Link non historisé Des événements qui ne changent jamais une fois écrits (des transactions)
Tables de référence Des listes de codes stables (pays, devises) : ce ne sont pas des hubs
PIT et Bridge Des tables d'aide aux requĂȘtes : elles accĂ©lĂšrent les jointures, sans rien stocker de nouveau

🧠 À retenir sur les relations

  • Ce qui dĂ©crit une relation va dans un satellite de link.
  • Une relation qui se termine ne s'efface pas : un satellite d'effectivitĂ© date son dĂ©but et sa fin.
  • DĂ©tecter une fin suppose une extraction complĂšte : une absence dans un flux partiel ne prouve rien.

Prochaine étape : interroger le Data Vault dans le temps, puis construire un data mart en étoile au-dessus.


7. Interroger le Data Vault dans le temps

7.1 Ce que l'entrepÎt savait, à une date donnée

Tout ce que tu as chargé porte une date_chargement. On peut donc reconstruire l'état de l'entrepÎt à n'importe quelle date : pour chaque client, on prend la derniÚre version de chaque satellite chargée avant cette date.

⚠ date_chargement dit quand l'entrepĂŽt a appris une information, pas quand elle est devenue vraie. Kouassi Ama a peut-ĂȘtre dĂ©mĂ©nagĂ© en mai ; l'entrepĂŽt ne l'a su que le 18 juin. Si le mĂ©tier a besoin de la date rĂ©elle du dĂ©mĂ©nagement, c'est un attribut Ă  demander Ă  la source et Ă  ranger dans un satellite — pas la date de chargement.

C'est exactement la question de l'auditeur : « qu'est-ce que vous saviez de ce client le 1er mars, quand vous avez pris cette décision ? »

Exercice 11 : L'état des clients à une date

Objectif : Ă©crire une macro de table etat_clients_au(d) — une requĂȘte paramĂ©trĂ©e qu'on interroge comme une table :

CREATE OR REPLACE MACRO etat_clients_au(d) AS TABLE
SELECT ...;

SELECT * FROM etat_clients_au(DATE '2026-03-01');

Elle renvoie, pour chaque client dĂ©jĂ  connu du hub Ă  la date d, les colonnes numero_client, nom, ville, telephone, email : la derniĂšre version de sat_client_agences et la derniĂšre version de sat_client_mobile chargĂ©es au plus tard Ă  d — ou NULL si le satellite n'a encore rien pour ce client.

💡 Deux CTE (une par satellite) avec le QUALIFY ROW_NUMBER() du §5, puis le hub en LEFT JOIN sur les deux.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE MACRO etat_clients_au(d) AS TABLE
--         WITH agences AS (...), mobile AS (...)
--         SELECT ... FROM hub_client ...
""")

voir("SELECT * FROM etat_clients_au(DATE '2026-03-01')")
pythonVoir le code
verifier_exercice(11)
💡 Solution
executer("""
CREATE OR REPLACE MACRO etat_clients_au(d) AS TABLE
WITH agences AS (
    SELECT hk_client, nom, ville
    FROM sat_client_agences
    WHERE date_chargement <= d
    QUALIFY ROW_NUMBER() OVER (
        PARTITION BY hk_client ORDER BY date_chargement DESC) = 1
),
mobile AS (
    SELECT hk_client, telephone, email
    FROM sat_client_mobile
    WHERE date_chargement <= d
    QUALIFY ROW_NUMBER() OVER (
        PARTITION BY hk_client ORDER BY date_chargement DESC) = 1
)
SELECT h.numero_client, a.nom, a.ville, m.telephone, m.email
FROM hub_client h
LEFT JOIN agences a USING (hk_client)
LEFT JOIN mobile m USING (hk_client)
WHERE h.date_chargement <= d
ORDER BY h.numero_client
""")

voir("SELECT * FROM etat_clients_au(DATE '2026-03-01')")

7.2 La table PIT : précalculer « quelle version, à quelle date »

La macro marche, mais chaque appel refait des fonctions de fenĂȘtre sur tous les satellites. Avec des millions de lignes et des dizaines de satellites, les tableaux de bord rament.

Une table PIT (point-in-time) précalcule la réponse une fois pour toutes. Pour chaque client et chaque date d'observation (ici, le premier jour de chaque mois), elle note la date de la version valable dans chaque satellite :

hk_client date_snapshot date_agences date_mobile
C-1042 2026-03-01 2026-01-05 2026-02-11
C-1042 2026-07-01 2026-06-18 2026-02-11
C-1045 2026-07-01 2026-01-05 NULL

Ensuite, retrouver l'Ă©tat Ă  une date devient une suite de jointures d'Ă©galitĂ© — les plus rapides qui soient. La PIT ne contient aucune donnĂ©e nouvelle : on peut la jeter et la recalculer quand on veut.

Les dates d'observation sont fournies :

pythonVoir le code
con.execute("""
CREATE OR REPLACE TABLE snapshots AS
SELECT CAST(generate_series AS DATE) AS date_snapshot
FROM generate_series(DATE '2026-01-01', DATE '2026-10-01', INTERVAL 1 MONTH)
""")
voir("SELECT * FROM snapshots")

Exercice 12 : Construire la table PIT

Objectif : créer la table pit_client (hk_client, date_snapshot, date_agences, date_mobile) :

  • une ligne par client dĂ©jĂ  connu du hub Ă  chaque date_snapshot ;
  • date_agences (resp. date_mobile) = la plus grande date_chargement de sat_client_agences (resp. sat_client_mobile) pour ce client, au plus tard Ă  date_snapshot — NULL s'il n'y en a pas.

La cellule suivante s'en sert pour retrouver l'état au 1er juillet avec de simples égalités. La vérification compare le résultat à ta macro de l'exercice 11, pour chaque date d'observation.

💡 CREATE OR REPLACE TABLE pit_client AS SELECT ... avec une sous-requĂȘte (SELECT MAX(...) FROM ... WHERE ...) par satellite.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE TABLE pit_client AS
--         SELECT h.hk_client, s.date_snapshot, (...) AS date_agences, (...) AS date_mobile
--         FROM hub_client h JOIN snapshots s ON ...
""")

# L'état au 1er juillet, avec la PIT : rien que des égalités
voir("""
SELECT h.numero_client, a.nom, a.ville, m.telephone, m.email
FROM pit_client p
JOIN hub_client h USING (hk_client)
LEFT JOIN sat_client_agences a
       ON a.hk_client = p.hk_client AND a.date_chargement = p.date_agences
LEFT JOIN sat_client_mobile m
       ON m.hk_client = p.hk_client AND m.date_chargement = p.date_mobile
WHERE p.date_snapshot = DATE '2026-07-01'
ORDER BY 1
""")
pythonVoir le code
verifier_exercice(12)
💡 Solution
executer("""
CREATE OR REPLACE TABLE pit_client AS
SELECT
    h.hk_client,
    s.date_snapshot,
    (SELECT MAX(a.date_chargement) FROM sat_client_agences a
      WHERE a.hk_client = h.hk_client
        AND a.date_chargement <= s.date_snapshot) AS date_agences,
    (SELECT MAX(m.date_chargement) FROM sat_client_mobile m
      WHERE m.hk_client = h.hk_client
        AND m.date_chargement <= s.date_snapshot) AS date_mobile
FROM hub_client h
JOIN snapshots s ON h.date_chargement <= s.date_snapshot
""")

# L'état au 1er juillet, avec la PIT : rien que des égalités
voir("""
SELECT h.numero_client, a.nom, a.ville, m.telephone, m.email
FROM pit_client p
JOIN hub_client h USING (hk_client)
LEFT JOIN sat_client_agences a
       ON a.hk_client = p.hk_client AND a.date_chargement = p.date_agences
LEFT JOIN sat_client_mobile m
       ON m.hk_client = p.hk_client AND m.date_chargement = p.date_mobile
WHERE p.date_snapshot = DATE '2026-07-01'
ORDER BY 1
""")

🧠 À retenir sur le temps

  • L'Ă©tat Ă  une date = la derniĂšre version de chaque satellite chargĂ©e avant cette date.
  • date_chargement est une date de connaissance, pas une date de vĂ©ritĂ© mĂ©tier.
  • Une PIT prĂ©calcule « quelle version, Ă  quelle date » : les requĂȘtes deviennent des Ă©galitĂ©s. Elle se reconstruit Ă  volontĂ©.

8. Restituer : un data mart en étoile au-dessus du Data Vault

8.1 Personne ne branche un tableau de bord sur le Raw Vault

Compte les tables que tu as construites pour deux concepts métier (un client, un compte) :

pythonVoir le code
voir("""
SELECT table_name
FROM information_schema.tables
WHERE regexp_matches(table_name, '^(hub|link|sat)_')
ORDER BY table_name
""")

Huit tables, et il faut des jointures et des fonctions de fenĂȘtre pour la moindre question. C'est le prix de la souplesse : le Data Vault est fait pour intĂ©grer et historiser, pas pour ĂȘtre lu par un analyste.

On construit donc au-dessus des tables simples, prĂȘtes Ă  l'emploi — les data marts, souvent en Ă©toile :

sources ──▶ staging ──▶ Data Vault ──▶ data marts en Ă©toile ──▶ tableaux de bord
                        (intĂšgre, historise)   (restituent, faciles Ă  lire)

Ces marts sont des vues (ou des tables recalculées) : si on se trompe, on les reconstruit depuis le Data Vault, qui, lui, n'a rien perdu.

â„č Entre les deux, beaucoup d'Ă©quipes ajoutent un Business Vault : des satellites calculĂ©s qui appliquent les rĂšgles mĂ©tier (« le nom fait foi s'il vient des agences », « un client sans compte actif depuis un an est inactif »). MĂȘmes briques, mĂȘmes rĂšgles — mais des donnĂ©es dĂ©rivĂ©es, pas livrĂ©es par une source.

Exercice 13 : La dimension client

Objectif : créer la vue dim_client, une ligne par client, avec les colonnes :

Colonne Contenu
numero_client, nom, ville, telephone, email l'Ă©tat d'aujourd'hui — ta macro etat_clients_au(CURRENT_DATE)
nb_comptes_actifs le nombre de relations client ↔ compte actives d'aprùs le dernier statut de sat_eff_client_compte (0 si aucune)

💡 Pour nb_comptes_actifs : le dernier statut de chaque hk_lien (QUALIFY), joint au link pour retrouver hk_client, puis COUNT(*) FILTER (WHERE actif). Et pour relier l'Ă©tat au hub : numero_client.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE VIEW dim_client AS
--         l'état d'aujourd'hui + le nombre de comptes actifs de chaque client
""")

voir("SELECT * FROM dim_client")
pythonVoir le code
verifier_exercice(13)
💡 Solution
executer("""
CREATE OR REPLACE VIEW dim_client AS
WITH statut AS (
    -- le dernier statut connu de chaque relation
    SELECT hk_lien, actif
    FROM sat_eff_client_compte
    QUALIFY ROW_NUMBER() OVER (
        PARTITION BY hk_lien ORDER BY date_chargement DESC) = 1
),
comptes AS (
    SELECT l.hk_client, COUNT(*) FILTER (WHERE s.actif) AS nb_comptes_actifs
    FROM link_client_compte l
    JOIN statut s USING (hk_lien)
    GROUP BY l.hk_client
)
SELECT e.numero_client, e.nom, e.ville, e.telephone, e.email,
       COALESCE(c.nb_comptes_actifs, 0) AS nb_comptes_actifs
FROM etat_clients_au(CURRENT_DATE) e
JOIN hub_client h USING (numero_client)
LEFT JOIN comptes c USING (hk_client)
""")

voir("SELECT * FROM dim_client")

8.2 L'historique en bonus : une dimension « SCD Type 2 »

Une dimension historisée (« slowly changing dimension de type 2 ») garde une ligne par version, avec ses dates de validité. Dans un schéma en étoile classique, c'est pénible à maintenir. Ici, c'est gratuit : le satellite contient déjà toutes les versions. Il suffit de les présenter.

numero_client   ville      valide_du     valide_au     est_courant
C-1042          Abidjan    2026-01-05    2026-06-18    false
C-1042          Bouaké     2026-06-18    (NULL)        true

Exercice 14 ⭐ (défi) : La dimension historisée

Objectif : créer la vue dim_client_historique à partir de sat_client_agences, avec les colonnes numero_client, nom, ville, valide_du, valide_au, est_courant :

  • valide_du = la date_chargement de la version ;
  • valide_au = la date_chargement de la version suivante du mĂȘme client, NULL s'il n'y en a pas ;
  • est_courant = vrai pour la derniĂšre version.

💡 LEAD(date_chargement) OVER (PARTITION BY hk_client ORDER BY date_chargement) donne la date de la version suivante.

â„č MĂȘme nuance qu'au §7.1 : valide_du est la date oĂč l'entrepĂŽt a appris la version, pas forcĂ©ment celle oĂč elle est devenue vraie.

pythonVoir le code
executer("""
-- À TOI : CREATE OR REPLACE VIEW dim_client_historique AS
--         une ligne par version de sat_client_agences, avec ses dates de validité
""")

voir("SELECT * FROM dim_client_historique ORDER BY numero_client, valide_du")
pythonVoir le code
verifier_exercice(14)
💡 Solution
executer("""
CREATE OR REPLACE VIEW dim_client_historique AS
SELECT
    h.numero_client,
    s.nom,
    s.ville,
    s.date_chargement AS valide_du,
    LEAD(s.date_chargement) OVER (
        PARTITION BY s.hk_client ORDER BY s.date_chargement) AS valide_au,
    LEAD(s.date_chargement) OVER (
        PARTITION BY s.hk_client ORDER BY s.date_chargement) IS NULL AS est_courant
FROM sat_client_agences s
JOIN hub_client h USING (hk_client)
""")

voir("SELECT * FROM dim_client_historique ORDER BY numero_client, valide_du")

🧠 À retenir sur la restitution

  • Le Data Vault intĂšgre et historise ; les data marts en Ă©toile restituent. Les deux se superposent, ils ne sont pas concurrents.
  • Les marts sont des vues ou des tables recalculĂ©es : on peut les jeter et les reconstruire depuis le Data Vault.
  • Une dimension historisĂ©e (SCD 2) se dĂ©rive d'un satellite, sans rien stocker de plus.

9. Quand choisir Data Vault
 et quand s'en passer

Tu viens d'en faire l'expĂ©rience : huit tables et quatre chargements pour deux concepts. Data Vault coĂ»te — en tables, en jointures, en compĂ©tences. Il vaut ce coĂ»t dans certains contextes seulement :

✅ Data Vault, quand
 ❌ Trop lourd, quand

les sources sont nombreuses et changent souvent il n'y a qu'une ou deux sources stables
il faut prouver d'oĂč vient chaque chiffre (banque, assurance, santĂ©) l'enjeu est de livrer des tableaux de bord vite
plusieurs Ă©quipes alimentent le mĂȘme entrepĂŽt l'Ă©quipe est petite (deux personnes n'ont pas les moyens d'un Data Vault)
l'historique complet est une obligation l'historique d'une ou deux dimensions suffit

Dans la colonne de droite, un schéma en étoile construit directement depuis le staging fait trÚs bien l'affaire.

💡 Remarque : tes chargements se ressemblaient tous. Hub, link, satellite : trois recettes, appliquĂ©es table aprĂšs table. C'est pour ça que les Ă©quipes Data Vault les gĂ©nĂšrent Ă  partir de mĂ©tadonnĂ©es, avec des packages dbt comme AutomateDV ou datavault4dbt. Tu sais maintenant ce que ces outils Ă©crivent Ă  ta place.

Le dernier critÚre, et souvent le plus important : qui va maintenir ce modÚle ? Le meilleur modÚle est celui que ton équipe sait faire évoluer. Pour situer Data Vault face au schéma en étoile, à l'architecture médaillon et à la One Big Table, voir le module 34 · Patterns & Décisions d'Architecture.


Quiz

Q1. Que contient un hub, et que ne contient-il jamais ?

RUne clé de hachage, la clé métier, la date de chargement et la source. Jamais d'attribut descriptif : tout ce qui change va dans les satellites.

Q2. Pourquoi une clé de hachage plutÎt qu'une séquence (1, 2, 3
) ?

RElle se calcule partout Ă  partir de la clĂ© mĂ©tier, sans interroger le hub : les hubs, links et satellites se chargent en parallĂšle, et deux environnements calculent la mĂȘme clĂ©.

Q3. L'application livre ' c-1042', les agences 'C-1042'. Que se passe-t-il sans normalisation ?

RDeux clés de hachage différentes, donc deux clients dans le hub pour une seule personne. On applique `TRIM` et `UPPER` dans le staging, avant de hacher.

Q4. OĂč ranger le rĂŽle « co-titulaire » d'un client dans un compte joint ?

RDans un satellite accroché au link : il ne décrit ni le client ni le compte, mais leur relation.

Q5. Un co-titulaire quitte le compte joint. Que fait-on dans le link ?

RRien : le link dit que la relation a existĂ©, ce qui reste vrai. On ajoute une ligne `actif = false` dans le satellite d'effectivitĂ© — ce qui suppose que la source livre des extractions complĂštes.

Q6. Pourquoi concat_ws est-il dangereux pour calculer un hash_diff ?

RIl ignore les `NULL` : `('a', NULL, 'b')` et `('a', 'b', NULL)` donnent le mĂȘme texte, donc la mĂȘme empreinte. On remplace chaque `NULL` par une valeur vide et on garde le sĂ©parateur.

Q7. Pourquoi comparer Ă  la derniĂšre version connue Ă  la date du lot, et pas Ă  la toute derniĂšre ?

RPour que rejouer un ancien lot ne crée rien : sinon, rejouer janvier aprÚs juin comparerait Abidjan à Bouaké et tenterait de réinsérer une version existante.

Q8. date_chargement indique-t-elle quand le client a déménagé ?

RNon : quand l'entrepÎt l'a appris. La date réelle, si le métier en a besoin, est un attribut à demander à la source.

Q9. À quoi sert une table PIT ?

RÀ prĂ©calculer, pour chaque clĂ© et chaque date d'observation, la version valable de chaque satellite. Les requĂȘtes historiques deviennent des jointures d'Ă©galitĂ©. Elle ne contient rien de nouveau et se reconstruit Ă  volontĂ©.

Q10. Pourquoi ne branche-t-on pas les tableaux de bord directement sur le Data Vault ?

RTrop de tables et de jointures, et aucune rÚgle métier appliquée. On construit au-dessus des data marts en étoile, faciles à lire, qu'on peut reconstruire à tout moment.

📚 Ressources


📝 RĂ©capitulatif

Brique Contient Se charge

Hub clé de hachage, clé métier, date, source en insérant les clés inconnues
Link sa clé (hachage de la combinaison) et les clés des hubs en insérant les relations inconnues
Satellite la description d'un hub ou d'un link, version par version en insérant si l'empreinte a changé depuis la derniÚre version connue à la date du lot
Satellite d'effectivité le statut actif / inactif d'une relation en insérant si le statut a changé
PIT la version valable de chaque satellite, Ă  chaque date en la recalculant
Data mart des vues simples pour les tableaux de bord en les recalculant depuis le Data Vault

🎉 Bravo ! Tu as construit un Data Vault complet : intĂ©grĂ© trois sources, historisĂ© chaque changement, datĂ© la fin d'une relation, interrogĂ© le passĂ©, et restituĂ© le tout en Ă©toile.

âžĄïž D'autres ateliers : dataeng.from0tohero.dev/ateliers