Lab 3 : Modélisation dbt Medallion — cas DHIS2
Ceci est la session pratique de « Lab 3 : Mettre en place l’infrastructure Medallion — Bronze, Silver, Gold » (jour 10 de l’événement), dans la continuité de Formation dbt : de l’installation au premier modèle et de Introduction à la modélisation des données : données agrégées et tracker de DHIS2, organisées en couches Bronze, Silver et Gold.
- Déclarer les tables DHIS2 brutes comme sources dbt dans la couche Bronze
- Construire des modèles Bronze fins, en vues 1:1 sur les tables brutes agrégées et tracker
- Conformer des modèles Silver qui résolvent les références UID/ID en noms lisibles
- Construire un schéma en étoile Gold avec des modèles dim_ et fact_ pour les données agrégées et tracker
- Configurer les matérialisations par couche et les tables de faits incrémentales dans dbt_project.yml
- Écrire des tests relationships, unique et not_null sur les couches Bronze, Silver et Gold
- Avoir terminé Formation dbt : de l’installation au premier modèle et Introduction à la modélisation des données
- dbt installé et connecté à un entrepôt (voir les prérequis de la formation dbt)
- Tables PostgreSQL brutes de DHIS2 (ou extraits de l’API Web déposés sous forme de
tables) répliquées dans un schéma
rawde cet entrepôt avant l’exécution de dbt
1. Introduction
DHIS2 stocke deux types de données fondamentalement différents : les données agrégées (des totaux rapportés par unité d’organisation, période et catégorie — p. ex. « 120 cas de paludisme dans le district de Kigali, mars 2026 ») et les données tracker (des enregistrements individuels — clients, enrôlements et événements, p. ex. l’historique des visites CPN d’une patiente). Les deux doivent arriver prêts à l’analyse dans l’entrepôt, mais ils partent de tables brutes très différentes.
Nous modélisons les deux à travers le même schéma en trois couches :
Bronze, Silver et Gold sont les noms « medallion » des couches landing, staging et marts vues dans le cours d’introduction.
Hypothèse pour cette démo : les tables PostgreSQL brutes de DHIS2 (ou des extraits
API déposés sous forme de tables) sont répliquées dans un schéma raw de l’entrepôt avant
l’exécution de dbt. Si vous extrayez plutôt via l’API Web DHIS2 vers du JSON/Parquet, la
couche Bronze devient simplement une étape de parsing — les couches Silver et Gold
ci-dessous restent identiques.
2. Rappel du modèle de données DHIS2
| Agrégé | Tracker |
|---|---|
| Une ligne = un nombre récapitulatif pour un combo unité d’organisation + période + élément de données + catégorie | Une ligne = un fait survenu à une personne/entité précise |
Table centrale : datavalue | Tables centrales : trackedentityinstance, programinstance, programstageinstance |
| Adapté à : reporting de routine (totaux HMIS) | Adapté à : analyse par cas / longitudinale (parcours patients) |
| Pas d’identité individuelle | Identité individuelle (dé-identifiée via UID) |
3. Architecture en couches
| Couche | Ce que c’est | Matérialisation |
|---|---|---|
| Bronze | Copie structurelle exacte des tables sources. Pas de renommage, pas de jointures, corrections de typage minimales. | Vues — peu coûteuses, toujours fraîches |
| Silver | Lisible, dédupliqué, typé et joint pour résoudre les références UID/ID en noms. Une ligne signifie toujours la même chose qu’en Bronze — on la rend simplement utilisable. | Vues ou tables |
| Gold | Schéma en étoile dimensionnel : modèles dim_ et fact_ séparés, prêts pour les outils BI (, PowerBI). | Tables, souvent incrémentales pour les grandes tables de faits |
4. Structure du projet
- bronze_dataelement.sql
- bronze_dataset.sql
- bronze_indicator.sql
- bronze_period.sql
- bronze_organisationunit.sql
- bronze_categorycombo.sql
- bronze_categoryoptioncombo.sql
- bronze_datavalue.sql
- bronze_trackedentityinstance.sql
- bronze_trackedentityattributevalue.sql
- bronze_program.sql
- bronze_programstage.sql
- bronze_programinstance.sql
- bronze_programstageinstance.sql
- bronze_trackedentitydatavalue.sql
- bronze_relationship.sql
- silver_org_units.sql
- silver_periods.sql
- silver_data_elements.sql
- silver_category_option_combos.sql
- silver_aggregate_datavalues.sql
- silver_tracked_entities.sql
- silver_enrollments.sql
- silver_events.sql
- silver_event_data_values.sql
- dim_org_unit.sql
- dim_period.sql
- dim_data_element.sql
- dim_category_option_combo.sql
- fact_aggregate_data_value.sql
- dim_tracked_entity.sql
- dim_program.sql
- fact_enrollment.sql
- fact_event.sql
- fact_event_data_value.sql
5. Bronze — Déclarer les sources
Chaque table brute est déclarée une seule fois dans models/bronze/sources.yml, pointant
vers le schéma où atterrissent les tables de DHIS2.
version: 2
sources:
- name: dhis2_raw
schema: raw
tables:
# aggregate
- name: dataelement
- name: dataset
- name: indicator
- name: period
- name: periodtype
- name: organisationunit
- name: _orgunitstructure
- name: categorycombo
- name: categoryoptioncombo
- name: categoryoption
- name: datavalue
# tracker
- name: trackedentityinstance
- name: trackedentityattribute
- name: trackedentityattributevalue
- name: program
- name: programstage
- name: programinstance
- name: programstageinstance
- name: trackedentitydatavalue
- name: relationship
- name: relationshiptype6. Bronze — Tables brutes agrégées
| Table | Ce qu’elle contient |
|---|---|
dataelement | Définitions de ce qui est mesuré (p. ex. « CPN 1re visite ») |
dataset | Regroupements d’éléments de données collectés ensemble sur un même formulaire |
indicator | Métriques calculées (formules numérateur/dénominateur) |
period | La période à laquelle une valeur s’applique (mensuelle, trimestrielle…) |
organisationunit | La hiérarchie établissement/district/région |
_orgunitstructure | Hiérarchie des unités d’organisation aplatie (noms des niveaux 1–5) |
categorycombo / categoryoptioncombo | Désagrégations (p. ex. par âge/sexe) |
datavalue | Le nombre effectivement rapporté — une ligne par unité d’organisation + période + élément de données + combo d’options de catégorie |
Les modèles Bronze sont minces — une simple sélection depuis la source, avec un léger typage :
-- models/bronze/aggregate/bronze_datavalue.sql
select
dataelementid,
periodid,
sourceid as organisationunitid,
categoryoptioncomboid,
attributeoptioncomboid,
cast(value as numeric) as value,
storedby,
lastupdated,
created
from {{ source('dhis2_raw', 'datavalue') }}-- models/bronze/aggregate/bronze_dataelement.sql
select
dataelementid,
uid,
name,
shortname,
domaintype, -- AGGREGATE or TRACKER
valuetype,
lastupdated
from {{ source('dhis2_raw', 'dataelement') }}-- models/bronze/aggregate/bronze_organisationunit.sql
select
organisationunitid,
uid,
name,
parentid,
path,
hierarchylevel
from {{ source('dhis2_raw', 'organisationunit') }}Les autres modèles Bronze agrégés (bronze_dataset, bronze_indicator, bronze_period,
bronze_categorycombo, bronze_categoryoptioncombo) suivent le même schéma un-pour-un.
7. Bronze — Tables brutes tracker
| Table | Ce qu’elle contient |
|---|---|
trackedentityinstance | Une ligne par personne/entité suivie (client, ménage…) |
trackedentityattribute | Définitions des attributs au niveau de la personne (p. ex. « Date de naissance ») |
trackedentityattributevalue | Les valeurs d’attributs effectives par entité |
program | Un programme tracker (p. ex. « Programme CPN ») |
programstage | Une étape au sein d’un programme (p. ex. « Visite CPN 1 ») |
programinstance | Un enrôlement — une entité inscrite dans un programme |
programstageinstance | Un événement — une occurrence d’une étape de programme |
trackedentitydatavalue | Les valeurs de données saisies lors d’un événement précis |
relationship / relationshiptype | Liens entre entités (p. ex. mère–enfant) |
-- models/bronze/tracker/bronze_trackedentityinstance.sql
select
trackedentityinstanceid,
uid,
organisationunitid,
trackedentitytypeid,
created,
lastupdated,
deleted
from {{ source('dhis2_raw', 'trackedentityinstance') }}-- models/bronze/tracker/bronze_programinstance.sql
select
programinstanceid,
uid,
trackedentityinstanceid,
programid,
organisationunitid,
enrollmentdate,
incidentdate,
status, -- ACTIVE, COMPLETED, CANCELLED
lastupdated
from {{ source('dhis2_raw', 'programinstance') }}-- models/bronze/tracker/bronze_programstageinstance.sql
select
programstageinstanceid,
uid,
programinstanceid,
programstageid,
organisationunitid,
executiondate,
duedate,
status, -- COMPLETED, SCHEDULE, SKIPPED, OVERDUE
lastupdated
from {{ source('dhis2_raw', 'programstageinstance') }}-- models/bronze/tracker/bronze_trackedentitydatavalue.sql
select
programstageinstanceid,
dataelementid,
value,
storedby,
lastupdated
from {{ source('dhis2_raw', 'trackedentitydatavalue') }}Les autres modèles Bronze tracker (bronze_trackedentityattribute,
bronze_trackedentityattributevalue, bronze_program, bronze_programstage,
bronze_relationship) suivent le même schéma.
$ dbt run --select bronze.*
...
16 of 16 OK created sql view model bronze.bronze_trackedentitydatavalue .... [CREATE VIEW in 0.11s]
Completed successfully
Done. PASS=16 WARN=0 ERROR=0 SKIP=0 TOTAL=16Vous ne voyez pas cela ?
ERROR relation "raw.datavalue" does not exist— la table source n’est pas encore répliquée, ou leschema: rawdesources.ymlne correspond pas à l’endroit réel où atterrissent les tables DHIS2. Vérifiez avecselect * from raw.datavalue limit 1;avant de relancer.- Une vue Bronze se construit mais retourne 0 ligne — le job de réplication en amont n’a pas encore alimenté cette table ; c’est un problème côté source, pas côté dbt. Vérifiez directement le schéma
raw. permission denied for schema raw— le rôle d’entrepôt utilisé par dbt n’a pas les droitsUSAGE/SELECTsurraw; accordez-les avant de continuer.
8. Silver — Modèles agrégés conformés
-- models/silver/aggregate/silver_org_units.sql
select
o.organisationunitid,
o.uid as org_unit_uid,
o.name as org_unit_name,
s.namelevel1 as national_name,
s.namelevel2 as province_name,
s.namelevel3 as district_name,
s.namelevel4 as facility_name,
o.hierarchylevel
from {{ ref('bronze_organisationunit') }} o
left join {{ source('dhis2_raw', '_orgunitstructure') }} s
on o.organisationunitid = s.organisationunitid-- models/silver/aggregate/silver_aggregate_datavalues.sql
select
dv.dataelementid,
de.name as data_element_name,
dv.organisationunitid,
dv.periodid,
p.startdate as period_start_date,
p.enddate as period_end_date,
dv.categoryoptioncomboid,
dv.value,
dv.lastupdated
from {{ ref('bronze_datavalue') }} dv
left join {{ ref('bronze_dataelement') }} de
on dv.dataelementid = de.dataelementid
left join {{ ref('bronze_period') }} p
on dv.periodid = p.periodid
where dv.value is not nullDe la même manière, silver_periods, silver_data_elements et
silver_category_option_combos résolvent les noms/hiérarchies pour les jointures de la
couche Gold.
9. Silver — Modèles tracker conformés
-- models/silver/tracker/silver_tracked_entities.sql
select
tei.trackedentityinstanceid,
tei.uid as tracked_entity_uid,
tei.organisationunitid,
ou.org_unit_name,
ou.district_name,
tei.created as registration_date,
tei.deleted
from {{ ref('bronze_trackedentityinstance') }} tei
left join {{ ref('silver_org_units') }} ou
on tei.organisationunitid = ou.organisationunitid
where tei.deleted = false-- models/silver/tracker/silver_enrollments.sql
select
pi.programinstanceid,
pi.uid as enrollment_uid,
pi.trackedentityinstanceid,
pi.programid,
prog.name as program_name,
pi.organisationunitid,
pi.enrollmentdate,
pi.incidentdate,
pi.status as enrollment_status
from {{ ref('bronze_programinstance') }} pi
left join {{ ref('bronze_program') }} prog
on pi.programid = prog.programid-- models/silver/tracker/silver_events.sql
select
psi.programstageinstanceid,
psi.uid as event_uid,
psi.programinstanceid,
psi.programstageid,
stg.name as program_stage_name,
psi.organisationunitid,
psi.executiondate,
psi.duedate,
psi.status as event_status
from {{ ref('bronze_programstageinstance') }} psi
left join {{ ref('bronze_programstage') }} stg
on psi.programstageid = stg.programstageid-- models/silver/tracker/silver_event_data_values.sql
select
tdv.programstageinstanceid,
tdv.dataelementid,
de.name as data_element_name,
tdv.value,
tdv.lastupdated
from {{ ref('bronze_trackedentitydatavalue') }} tdv
left join {{ ref('bronze_dataelement') }} de
on tdv.dataelementid = de.dataelementid$ dbt run --select silver.*
...
Done. PASS=9 WARN=0 ERROR=0 SKIP=0 TOTAL=9
select org_unit_name, district_name from silver_org_units limit 3;
org_unit_name | district_name
---------------------------+------------------
Kigali Health Center | Kigali District
Nyagatare District Hosp. | Nyagatare District
Huye Health Post | Huye DistrictVous ne voyez pas cela ?
district_nameestnullsur la plupart des lignes — leleft joinvers_orgunitstructurene matche pas parce quehierarchyleveldiffère entre les deux tables, ou quenamelevel3/namelevel4correspondent au mauvais niveau dans la hiérarchie de votre instance DHIS2. Vérifiez ce mapping face àorganisationunit.path.silver_aggregate_datavaluesretourne 0 ligne alors quebronze_datavaluecontient des données — lecast(value as numeric)du Bronze a échoué silencieusement sur une chaîne non numérique en amont, ou le filtrewhere dv.value is not nullélimine tout parce que la jointure versbronze_period/bronze_dataelementa produit des nulls inattendus.- Vous voyez encore des identifiants bruts au lieu de noms — vous interrogez la table Bronze, pas la table Silver ; Bronze reste volontairement clé sur les identifiants bruts.
10. Gold — Schéma en étoile agrégé
-- models/gold/aggregate/dim_org_unit.sql
select distinct
organisationunitid as org_unit_key,
org_unit_uid,
org_unit_name,
national_name,
province_name,
district_name,
facility_name,
hierarchylevel
from {{ ref('silver_org_units') }}-- models/gold/aggregate/dim_period.sql
select distinct
periodid as period_key,
period_start_date,
period_end_date,
extract(year from period_start_date) as year,
extract(quarter from period_start_date) as quarter,
extract(month from period_start_date) as month
from {{ ref('silver_aggregate_datavalues') }}-- models/gold/aggregate/fact_aggregate_data_value.sql
select
dataelementid as data_element_key,
organisationunitid as org_unit_key,
periodid as period_key,
categoryoptioncomboid as category_option_combo_key,
value,
lastupdated
from {{ ref('silver_aggregate_datavalues') }}Pourquoi cette forme : une seule table de faits (fact_aggregate_data_value) jointe
aux dimensions offre aux outils BI une surface de requête unique et rapide — « montre-moi
les visites CPN par district et par trimestre » devient une simple jointure, quelle que
soit la structure des tables brutes de DHIS2.
11. Gold — Schéma en étoile tracker
-- models/gold/tracker/dim_tracked_entity.sql
select distinct
trackedentityinstanceid as tracked_entity_key,
tracked_entity_uid,
org_unit_name,
district_name,
registration_date
from {{ ref('silver_tracked_entities') }}-- models/gold/tracker/dim_program.sql
select distinct
programid as program_key,
program_name
from {{ ref('silver_enrollments') }}-- models/gold/tracker/fact_enrollment.sql
select
programinstanceid as enrollment_key,
trackedentityinstanceid as tracked_entity_key,
programid as program_key,
organisationunitid as org_unit_key,
enrollmentdate,
incidentdate,
enrollment_status
from {{ ref('silver_enrollments') }}-- models/gold/tracker/fact_event.sql
select
programstageinstanceid as event_key,
programinstanceid as enrollment_key,
programstageid as program_stage_key,
organisationunitid as org_unit_key,
executiondate,
duedate,
event_status
from {{ ref('silver_events') }}-- models/gold/tracker/fact_event_data_value.sql
select
programstageinstanceid as event_key,
dataelementid as data_element_key,
data_element_name,
value
from {{ ref('silver_event_data_values') }}$ dbt run --select gold.*
...
Done. PASS=10 WARN=0 ERROR=0 SKIP=0 TOTAL=10
select o.district_name, p.quarter, sum(f.value) as total
from fact_aggregate_data_value f
join dim_org_unit o on f.org_unit_key = o.org_unit_key
join dim_period p on f.period_key = p.period_key
group by 1, 2
order by 1, 2;
district_name | quarter | total
-------------------+---------+-------
Kigali District | 1 | 842
Kigali District | 2 | 915
Nyagatare District| 1 | 367Vous ne voyez pas cela ?
- La jointure vers
dim_org_unitoudim_periodne retourne aucune ligne — un décalage de type de clé entre la table de faits et la dimension (p. ex.org_unit_keystocké enbigintdans un modèle et casté entextdans un autre). Vérifiez que les modèles de dimension (select distinct) et le modèle de faits utilisent le même type de colonne source. fact_aggregate_data_valuecontient des doublons après un seconddbt run— leunique_keyde la config incrémentale dansdbt_project.ymlne correspond pas au grain réel de la table de faits ; relancez avec--full-refreshune fois corrigé.dim_programoudim_tracked_entitymanque des lignes présentes dans Silver — rappelez-vous que ces dimensions sont construites viaselect distinctsur un modèle Silver qui a déjà filtré certaines lignes (p. ex.where tei.deleted = false) ; vérifiez d’abord la clausewheredu modèle Silver.
12. Configuration des matérialisations
Définissez les matérialisations par défaut de chaque couche dans dbt_project.yml, afin
que personne n’ait à configurer chaque modèle individuellement :
# dbt_project.yml
models:
my_dhis2_project:
bronze:
+materialized: view
silver:
+materialized: view
gold:
+materialized: table
aggregate:
fact_aggregate_data_value:
+materialized: incremental
+unique_key: [data_element_key, org_unit_key, period_key, category_option_combo_key]
tracker:
fact_event_data_value:
+materialized: incremental
+unique_key: [event_key, data_element_key]13. Stratégie de tests
| Couche | Quoi tester |
|---|---|
| Bronze | not_null sur les clés primaires — détecter tôt une réplication cassée |
| Silver | Tests relationships — confirmer que chaque clé étrangère se résout (p. ex. l’unité d’organisation de chaque datavalue existe) |
| Gold | unique + not_null sur les clés de dimensions ; contrôles de fraîcheur par comptage de lignes sur les faits |
# models/gold/aggregate/schema.yml
version: 2
models:
- name: dim_org_unit
columns:
- name: org_unit_key
tests: [unique, not_null]
- name: fact_aggregate_data_value
columns:
- name: org_unit_key
tests:
- relationships:
to: ref('dim_org_unit')
field: org_unit_key$ dbt test
...
Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6Vous ne voyez pas cela ?
- Le test
relationshipssurorg_unit_keyÉCHOUE — une ligne de faits référence une unité d’organisation fusionnée ou supprimée dans DHIS2 après l’enregistrement du fait. Réintégrez les unités historiques (supprimées en douceur) dansdim_org_unit, ou filtrez les lignes orphelines dès le modèle Silver. not_nullÉCHOUE sur une clé de dimension — leselect distinctdu modèle Gold laisse passer unnullissu d’une jointure Silver non résolue en amont ; remontez cenulljusqu’auleft joinSilver qui l’a produit.uniqueÉCHOUE surorg_unit_key—dim_org_unitest construit avecselect distinctsur des colonnes qui ne sont pas réellement uniques par unité d’organisation (p. ex.org_unit_namese répète entre districts) ; le distinct sur la ligne entière n’équivaut pas à un distinct sur la clé.
14. Exécuter le pipeline
# run everything, in dependency order
dbt run
# run just the aggregate branch, bronze through gold
dbt run --select path:models/bronze/aggregate+ path:models/silver/aggregate+ path:models/gold/aggregate+
# run just the tracker branch
dbt run --select path:models/bronze/tracker+ path:models/silver/tracker+ path:models/gold/tracker+
# run one layer at a time (good for a live walkthrough)
dbt run --select bronze.*
dbt run --select silver.*
dbt run --select gold.*
# test everything
dbt test15. Déroulé de la démo en direct
- Montrer les tables brutes
datavalueettrackedentityinstancedirectement dans l’entrepôt — souligner à quel point les identifiants sont illisibles - Lancer
dbt run --select bronze.*— des vues 1:1 apparaissent, toujours avec les identifiants bruts - Lancer
dbt run --select silver.*— les noms et hiérarchies se résolvent, les données deviennent lisibles - Lancer
dbt run --select gold.*— les dimensions et les faits apparaissent - Interroger
fact_aggregate_data_valuejoint àdim_org_unit+dim_period— « les visites CPN par district et par trimestre » en une simple jointure SQL - Interroger
fact_eventjoint àdim_tracked_entity— l’historique complet des visites d’un patient en une seule jointure - Lancer
dbt test— montrer les tests de relations attrapant une référence d’unité d’organisation délibérément cassée
$ dbt test --select fact_aggregate_data_value
1 of 1 START test relationships_fact_aggregate_data_value_org_unit_key__org_unit_key__ref_dim_org_unit_
1 of 1 FAIL 1 relationships_fact_aggregate_data_value_org_unit_key__org_unit_key__ref_dim_org_unit_ .. [FAIL 1 in 0.08s]
Done. PASS=0 WARN=0 ERROR=0 FAIL=1 SKIP=0 TOTAL=1Vous ne voyez pas cela ?
- Le test PASSE alors qu’il devrait ÉCHOUER — l’identifiant d’unité d’organisation délibérément cassé doit être inséré dans la source
rawavant de relancerdbt run, pas après ; une table Gold non rafraîchie reflète encore les données propres précédentes. - Le test ÉCHOUE mais le nombre de faits est à 0 — vous avez ciblé
fact_eventau lieu defact_aggregate_data_value; la démo de référence cassée ci-dessus est écrite pour la branche agrégée. dbt runplante au lieu de faire échouer le test — vous avez cassé une colonne de clé étrangèrenot null(en la mettant àNULL) plutôt que de la pointer vers une unité d’organisation inexistante ; utilisez un identifiant absent deorganisationunit, pas un null.