Fastodata / Onboarding

Module d'onboarding — dbt (data build tool)

Contenu pédagogique produit par Bill (QA). Destiné à être intégré dans onboarding.fastodata.com.
Public : ingénieur data avec SQL solide. Format : théorie d'abord → exercices → quiz corrigé.
Stack de référence de l'équipe : dbt + BigQuery (+ orchestration Kestra/Airflow).
Durée estimée : ~3-4 h de lecture active + exercices.


Objectifs pédagogiques

À la fin de ce module, l'apprenant sait :

  1. Expliquer le rôle de dbt (le « T » de l'ELT) et ce qu'il ne fait pas.
  2. Construire un lignage avec ref() / source() et lire un DAG.
  3. Choisir la bonne matérialisation (view / table / incremental / ephemeral).
  4. Écrire tests et documentation en YAML.
  5. Utiliser Jinja & macros, et débugger avec dbt compile.
  6. Comprendre snapshots (SCD2), sources/freshness, packages.

Module 1 — Ce qu'est dbt (et ce qu'il n'est pas)

dbt compile du SQL et l'exécute dans le data warehouse (BigQuery ici). Il ne stocke ni ne calcule rien lui-même : l'entrepôt fait le travail, dbt orchestre.

Apports au-dessus du SQL brut :

  • Dépendances automatiques via ref() → DAG + ordre d'exécution.
  • Tests et doc versionnés avec le code (git).
  • Réutilisation via Jinja/macros.
  • Environnements (dev/prod) via les target du profiles.yml.

⚠️ Piège mental : un modèle dbt n'est pas un script CREATE TABLE. On écrit uniquement le SELECT ; dbt génère le CREATE … AS selon la matérialisation.


Module 2 — ref(), source() et le DAG

  • {{ source('raw', 'sales') }} → table brute externe déclarée dans un YAML sources. Porte d'entrée de la donnée.
  • {{ ref('stg_sales') }}un autre modèle dbt. Jamais le nom de table en dur.

dbt lit tous les ref()/source(), déduit le DAG, et ordonne l'exécution automatiquement.

Sélection par graphe :

  • dbt run --select stg_sales+ → ce modèle et tout son aval.
  • dbt run --select +mart_revenue → le modèle et tout son amont.
  • dbt run --select +int_orders+ → amont et aval.

Architecture en couches (standard équipe) : staging (stg_) → intermediate (int_) → publish/marts.

  • staging : 1 modèle/source, nettoyage/renommage/cast, matérialisé view.
  • intermediate : jointures, logique métier réutilisable.
  • marts : tables finales BI, matérialisé table.

Module 3 — Les matérialisations

Type Génère Quand
view CREATE VIEW staging, transfo légère
table CREATE TABLE AS (full refresh) marts lus souvent, calcul lourd
incremental insère/merge seulement le nouveau grosses tables append-only
ephemeral pas d'objet ; inliné en CTE chez l'aval logique intermédiaire jamais requêtée seule

Incremental (clé sur BigQuery) :

{{ config(materialized='incremental', unique_key='order_id') }}
select * from {{ source('raw','orders') }}
{% if is_incremental() %}
  where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
  • is_incremental() = vrai seulement si la table existe ET pas en --full-refresh.
  • {{ this }} = le modèle lui-même.
  • unique_key → merge au lieu d'insert (évite les doublons).

Module 4 — Tests & documentation (YAML)

models:
  - name: stg_sales
    description: "Ventes nettoyées, une ligne par transaction"
    columns:
      - name: sale_id
        tests: [unique, not_null]
      - name: customer_id
        tests:
          - not_null
          - relationships: { to: ref('stg_customers'), field: customer_id }
      - name: status
        tests:
          - accepted_values: { values: ['paid','refunded','pending'] }

4 tests génériques natifs : unique, not_null, relationships, accepted_values.
Au-delà → package dbt-utils ou tests singuliers (.sql dans tests/ qui doit renvoyer 0 ligne).

dbt build = run + test dans l'ordre du DAG (le réflexe vs run seul).


Module 5 — Jinja & macros

  • {{ ... }} = expression (produit du SQL injecté). {% ... %} = instruction (logique).
  • Variables : {{ var("start_date", "2024-01-01") }}.
  • Boucle (pivot manuel) :
{% for s in ['paid','refunded','pending'] %}
sum(case when status = '{{ s }}' then amount end) as amount_{{ s }}{{ "," if not loop.last }}
{% endfor %}
  • Macro (fonction SQL réutilisable, dans macros/) :
{% macro cents_to_euros(column) %}round({{ column }} / 100.0, 2){% endmacro %}

Réflexe debug n°1 : dbt compile → SQL final dans target/compiled/. Toujours le 1er endroit à regarder.
Piège : Jinja s'exécute à la compilation, jamais au runtime. Il ne voit pas les données. Pour boucler sur des valeurs en base → run_query()/statement (introspection).


Module 6 — Snapshots (SCD type 2)

Historiser les changements d'un enregistrement dans le temps (ex. une commande qui change de statut).

{% snapshot orders_snapshot %}
{{ config(target_schema='snapshots', unique_key='order_id',
          strategy='timestamp', updated_at='updated_at') }}
select * from {{ source('raw','orders') }}
{% endsnapshot %}
  • strategy='timestamp' → détecte un changement via une colonne de date.
  • strategy='check' → compare une liste de colonnes.
  • dbt gère dbt_valid_from / dbt_valid_to → tu retrouves l'état à n'importe quelle date.

Module 7 — Sources, freshness & packages

  • Sources déclarées en YAML → source(), doc, et tests de fraîcheur :
sources:
  - name: raw
    tables:
      - name: orders
        loaded_at_field: _loaded_at
        freshness:
          warn_after:  { count: 12, period: hour }
          error_after: { count: 24, period: hour }

dbt source freshness → alerte si la donnée brute est trop vieille.

  • Packages (packages.yml + dbt deps) : dbt-utils (tests/macros), codegen, dbt-expectations.

Exercices (fil rouge : un mini-pipeline ventes)

  1. Staging : écris stg_orders à partir de source('raw','orders') — caste amount_cents en int, renomme tsorder_at. Matérialisation ? Justifie.
  2. Tests : ajoute le YAML — order_id unique+not_null, status ∈ {paid,refunded,pending}, customer_id en relation avec stg_customers.
  3. Incremental : transforme fct_orders (10 M lignes, append-only) en incremental sur order_id filtré par order_at. Écris le bloc is_incremental().
  4. Macro : crée cents_to_euros() et utilise-la dans fct_orders.
  5. Sélection : quelle commande pour rejouer fct_orders ET tout ce qui en dépend, sans toucher l'amont ?

Quiz final corrigé

Q1. Pourquoi {{ ref('x') }} plutôt qu'un nom de table en dur ?
→ Construit le DAG/ordre auto ; environment-aware (dev/prod) ; refactor sûr.

Q2. Table d'événements 2 Md append-only : matérialisation + rôle de is_incremental() ?
incremental (sinon rebuild complet = lent/cher). is_incremental() n'est vrai que si la table existe et hors --full-refresh ; il borne le WHERE aux nouvelles lignes tout en autorisant le rebuild complet.

Q3. relationships vs accepted_values ?
relationships = intégrité référentielle (valeur existe dans une autre table). accepted_values = appartenance à une liste figée (contrainte de domaine).

Q4. dbt run --select +int_orders exécute quoi ?
int_orders et tout son amont (le + devant = ancêtres). Pas l'aval.

Q5. ephemeral : à quoi ça sert, qu'est-ce qui est créé en base ?
→ Factoriser une logique intermédiaire jamais requêtée seule ; aucun objet créé, dbt l'inline en CTE chez l'aval.

Q6. {% for %} s'exécute avant ou pendant la requête BigQuery ? Conséquence ?
→ Avant (compilation Jinja). Conséquence : ne peut pas dépendre d'une valeur de ligne ; pour du dynamique-données → run_query().

Q7. Modèle au comportement bizarre, plein de Jinja. Première commande ?
dbt compile, puis lire target/compiled/… pour voir le SQL réel envoyé.


Pour aller plus loin

  • Doc officielle dbt (concepts, materializations, tests).
  • Normes DBT internes (Confluence, espace BI — page « Normes DBT »).
  • Skills équipe : dbt, dbt-modeling, bigquery, kestra.