đ ïž 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 :
- Chaque ligne porte sa date de chargement et sa source. C'est ce qui rend l'entrepĂŽt auditable.
- On ne fait qu'ajouter des lignes. Jamais d'
UPDATE, jamais deDELETE: 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 pandaspythonVoir 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 destg_agences_clientsv_stg_mobile_clients, Ă partir destg_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.
4. Le link â relier des hubs
4.1 Ce que contient 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.
4.2 La clé d'un link, et le séparateur
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
""")
Exercice 5 : Charger le hub des comptes et le link
Objectif :
- créer
hub_compteâ mĂȘme recette quehub_client, avechk_compteetnumero_compte; - crĂ©er
link_client_compteavec les 5 colonnes du §4.1 (clé primairehk_lien, aucune colonneNULL) ; - compléter
charger_hub_compte(lot)etcharger_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-502apparaß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 :
- créer
sat_client_agencesavec les colonneshk_client, date_chargement, hash_diff, nom, ville, sourceâ clĂ© primaire composĂ©e(hk_client, date_chargement); - 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
6.1 Le satellite de link
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)
Exercice 9 : Le satellite de link
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 :
actifvaut vrai si la relation figure dans le lot dev_stg_agences_comptes, faux sinon ;- 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. Eta IS DISTINCT FROM bcompare deux valeurs mĂȘme quand l'une estNULL.â ïž Â« 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_chargementdit 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 enLEFT JOINsur 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 grandedate_chargementdesat_client_agences(resp.sat_client_mobile) pour ce client, au plus tard Ădate_snapshotâNULLs'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_chargementest 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 chaquehk_lien(QUALIFY), joint au link pour retrouverhk_client, puisCOUNT(*) 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= ladate_chargementde la version ;valide_au= ladate_chargementde la version suivante du mĂȘme client,NULLs'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_duest 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 ?
R
Une 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âŠ) ?
R
Elle 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 ?
R
Deux 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 ?
R
Dans 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 ?
R
Rien : 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 ?
R
Il 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 ?
R
Pour 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é ?
R
Non : 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 ?
R
Trop 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
- Dan Linstedt & Michael Olschimke, Building a Scalable Data Warehouse with Data Vault 2.0 (Morgan Kaufmann, 2015) â la rĂ©fĂ©rence, par l'inventeur de la mĂ©thode
- Hans Hultgren, Modeling the Agile Data Warehouse with Data Vault â la modĂ©lisation, comparĂ©e Ă la 3NF et au dimensionnel
- Patrick Cuba, The Data Vault Guru â la mise en Ćuvre : variantes de satellites, requĂȘtes, PIT et Bridge
- AutomateDV et datavault4dbt â gĂ©nĂ©rer les chargements avec dbt
- Dans le bootcamp : module 34 · Patterns & Décisions d'Architecture et la question 4 de l'entretien SQL & modélisation
đ 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