ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Macros dbt

Comment fonctionnent les macros dbt — fondamentaux Jinja, écriture de macros personnalisées, utilisation de dbt_utils, patterns dispatch, et quand les macros aident ou nuisent

Planté Dernier soin
dbtdata engineeringdata modeling

Les macros sont le mécanisme de réutilisation du code dans dbt. Ce sont des templates Jinja qui génèrent du SQL à la compilation, transformant des patterns répétés en fonctions appelables. Une conversion de devise en cinq lignes dupliquée dans douze modèles devient {{ cents_to_dollars('amount') }} dans chacun et une source de vérité unique dans votre répertoire macros/.

La puissance est réelle, mais le risque aussi. Les macros sur-abstraites cachent du SQL simple derrière des couches que personne ne peut déboguer. L’objectif est de savoir quand une macro aide réellement et quand le SQL inline est le meilleur choix.

Fondamentaux Jinja

dbt utilise Jinja2 comme moteur de templating avec une particularité critique : les templates se rendent en deux phases. La phase de parsing construit le DAG et résout les dépendances. La phase d’exécution génère le SQL réel envoyé à votre entrepôt. Toute macro qui interroge la base de données (via run_query() ou qui accède à graph.nodes) doit se protéger contre la phase de parsing en utilisant le flag execute.

Variables et flux de contrôle

{% set %} crée des variables. Combinez avec des boucles et des conditionnelles pour générer du SQL dynamique :

{% set payment_methods = ["bank_transfer", "credit_card", "gift_card"] %}
{% for method in payment_methods %}
SUM(CASE WHEN payment_method = '{{ method }}' THEN amount END)
AS order__{{ method }}_amount
{{ ',' if not loop.last }}
{% endfor %}

Cela produit trois colonnes SUM(CASE WHEN...). La variable loop.last gère les virgules de fin, l’un des points de douleur les plus courants dans le SQL généré.

La gestion des espaces compte pour une sortie compilée lisible. Utilisez {%- pour supprimer les espaces avant un tag et -%} pour les supprimer après.

Anatomie d’une macro

Les macros résident dans des fichiers .sql sous macros/. Une macro de transformation basique :

{% macro cents_to_dollars(column_name, scale=2) %}
ROUND({{ column_name }} / 100.0, {{ scale }})
{% endmacro %}

Appelez-la avec {{ cents_to_dollars('amount_cents') }} ou remplacez la précision : {{ cents_to_dollars('amount_cents', scale=4) }}. Les paramètres requis viennent en premier sans valeurs par défaut ; les paramètres optionnels ont des valeurs par défaut sensées. Visez cinq à sept paramètres maximum.

La fonction return() préserve les types de données quand on doit retourner des valeurs (dictionnaires, listes, entiers). Le contenu entre les tags de macro se rend comme sortie de chaîne, qui devient le SQL généré.

Pour les macros qui ont besoin de flexibilité sans explosion de paramètres, utilisez **kwargs :

{% macro flexible_macro(required_param, **kwargs) %}
{% set optional = kwargs.get('optional_param', 'default') %}
-- utilisez optional ici
{% endmacro %}

C’est préférable à l’ajout du quinzième paramètre à une signature déjà longue.

Le garde execute

Toute macro qui exécute des requêtes à la compilation nécessite ce pattern :

{% macro get_column_values(table, column) %}
{% if not execute %}
{{ return([]) }}
{% endif %}
{% set query %}
SELECT DISTINCT {{ column }} FROM {{ table }}
{% endset %}
{% set results = run_query(query) %}
{{ return(results.columns[0].values()) }}
{% endmacro %}

Sans la vérification execute, run_query() échouerait pendant le parsing quand la table cible pourrait ne pas encore exister. Le retour de liste vide pendant la phase de parsing satisfait dbt ; la vraie requête s’exécute à l’exécution.

Objets de contexte

dbt fournit des objets de contexte d’exécution que vous utiliserez constamment :

  • this — la relation en cours de construction (.database, .schema, .identifier)
  • target — la cible de déploiement (.name, .type, .schema), utile pour le comportement tenant compte de l’environnement
  • model — les métadonnées du modèle courant (.unique_id, .config, .tags)
  • graph — les informations des nœuds à l’échelle du projet (.nodes, .sources), uniquement disponible à l’exécution

Macros de packages essentielles

Avant d’écrire des macros personnalisées, vérifiez si un package résout déjà votre problème. Le package dbt-utils (v1.3.3) fournit des macros éprouvées pour les patterns courants.

generate_surrogate_key crée des clés hashées à partir de colonnes métier, gérant les nulls de façon cohérente et prévenant les bugs de collision :

{{ dbt_utils.generate_surrogate_key(['customer_id', 'order_date']) }} AS order_key

star génère des listes de colonnes en excluant des colonnes spécifiques. Quand les modèles en amont ajoutent des colonnes, votre modèle les récupère automatiquement :

SELECT
{{ dbt_utils.star(from=ref('base__shopify__customers'),
except=["_loaded_at", "_source"]) }}
FROM {{ ref('base__shopify__customers') }}

get_column_values permet du SQL dynamique basé sur les données réelles, utile pour les requêtes de type pivot qui s’adaptent quand de nouvelles catégories apparaissent en production :

{% set methods = dbt_utils.get_column_values(
table=ref('base__shopify__payments'),
column='payment_method',
default=['credit_card', 'bank_transfer']
) %}

Fournissez toujours une liste default comme repli, puisque la requête d’introspection échoue si la relation cible n’existe pas encore (premier run, nouvel environnement).

date_spine crée des séquences de dates continues pour les modèles de séries temporelles qui doivent afficher des zéros plutôt que des lacunes. union_relations combine des tables avec des schémas différents, alignant les colonnes et remplissant les colonnes manquantes avec NULL.

Depuis dbt-utils v1.0, toutes les macros cross-base de données (fonctions de date, fonctions de chaîne, cast de type) ont migré vers le namespace dbt dans dbt-core. Utilisez dbt.dateadd() plutôt que dbt_utils.dateadd().

Patterns qui méritent d’être écrits soi-même

Certaines macros sont trop spécifiques à un projet pour des packages mais apparaissent projet après projet.

Les colonnes d’audit suivent les métadonnées ETL sur chaque ligne :

{% macro add_audit_columns() %}
CURRENT_TIMESTAMP() AS _loaded_at,
'{{ invocation_id }}' AS _dbt_invocation_id,
'{{ target.name }}' AS _dbt_target
{% endmacro %}

L’invocation ID vous permet de tracer les lignes jusqu’aux runs dbt spécifiques lors des incidents en production.

La limitation tenant compte de l’environnement accélère les exécutions de développement sans changer le comportement de production :

{% macro limit_data_in_dev(column_name='created_at', days=3) %}
{% if target.name == 'dev' %}
AND {{ column_name }} >= CURRENT_DATE() - {{ days }}
{% endif %}
{% endmacro %}

Ajoutez-la aux clauses WHERE : WHERE 1=1 {{ limit_data_in_dev() }}. En dev, vous interrogez trois jours. En prod, la clause disparaît. Plus besoin de commenter et décommenter les filtres avant les commits.

La génération de schéma remplace le nommage par défaut de dbt pour que la prod obtienne des noms de schéma propres tandis que le dev reste isolé :

{% macro generate_schema_name(custom_schema_name, node) %}
{% if target.name == 'prod' and custom_schema_name is not none %}
{{ custom_schema_name | trim }}
{% else %}
{{ target.schema }}{% if custom_schema_name is not none %}_{{ custom_schema_name | trim }}{% endif %}
{% endif %}
{% endmacro %}

Cette macro est appelée automatiquement par dbt. Un modèle avec schema: marts atterrit dans marts en prod et dbt_votrenom_marts en dev.

Le pattern dispatch pour les macros cross-base de données

Quand vos macros doivent fonctionner sur BigQuery, Snowflake et Databricks, le pattern dispatch vous permet d’écrire des implémentations spécifiques à l’adaptateur derrière une interface unique :

{% macro my_dateadd(datepart, interval, from_date) %}
{{ return(adapter.dispatch('my_dateadd')(datepart, interval, from_date)) }}
{% endmacro %}
{% macro default__my_dateadd(datepart, interval, from_date) %}
dateadd({{ datepart }}, {{ interval }}, {{ from_date }})
{% endmacro %}
{% macro bigquery__my_dateadd(datepart, interval, from_date) %}
date_add({{ from_date }}, interval {{ interval }} {{ datepart }})
{% endmacro %}

dbt recherche les implémentations dans l’ordre : spécifique à l’adaptateur, adaptateur parent, puis défaut. Commencez par un défaut qui couvre la plupart des bases de données, puis ajoutez des surcharges uniquement là où les dialectes SQL divergent. Les plus grandes divergences sont dans les fonctions de date (ordre des arguments), les opérations sur les tableaux (UNNEST vs LATERAL FLATTEN vs EXPLODE), et le cast sécurisé (SAFE_CAST vs TRY_CAST).

Avant d’écrire des macros dispatch personnalisées, vérifiez si dbt.dateadd(), dbt.datediff(), dbt.safe_cast() ou dbt.concat() gèrent déjà votre cas.

Quand les macros aident ou nuisent

La règle des trois

Première occurrence : écrivez inline. Deuxième occurrence : notez le pattern. Troisième occurrence : extrayez en macro. L’abstraction prématurée crée plus de problèmes que la duplication — un modèle qui nécessite la lecture de quatre fichiers de macros pour comprendre une seule requête est illisible.

Signes qu’une macro fait trop

Quand une macro dépasse cinq ou six paramètres, elle combine probablement plusieurs responsabilités. Une macro process_amount avec des flags pour l’application de remises, la conversion de devises et le calcul de taxes devrait être trois macros séparées (cents_to_dollars, apply_discount, convert_currency) qui se composent explicitement dans le modèle. Le code de modèle plus long est une fonctionnalité — quiconque le lit peut voir exactement ce qui se passe.

Conventions de nommage

Utilisez des préfixes verbaux qui indiquent ce que fait la macro. get_ récupère des données, generate_ crée une sortie, format_ transforme des chaînes. Préfixez les helpers internes d’underscore : _build_filter_clause. Un nommage clair rend les macros découvrables sans lire l’implémentation.

Organisation du projet

Une macro par fichier, nom de fichier identique au nom de macro. Les macros de surcharge (generate_schema_name) résident à la racine de macros/. Les macros utilitaires vont dans utils/. Les macros spécifiques à un domaine se regroupent par domaine métier. Cette organisation reflète les principes de l’architecture trois couches : des frontières claires rendent le projet navigable.

macros/
├── _macros.yml # Toute la documentation des macros
├── generate_schema_name.sql # Macros de surcharge à la racine
├── utils/
│ ├── string_utils.sql
│ └── date_utils.sql
├── transformations/
│ ├── finance/
│ └── marketing/
└── tests/ # Tests génériques personnalisés

Documentez les arguments dans _macros.yml pour qu’ils apparaissent dans les docs dbt :

macros:
- name: cents_to_dollars
description: |
Convertit des centimes entiers en dollars avec précision.
## Usage
```sql
{{ cents_to_dollars('amount_cents', scale=4) }}
```
arguments:
- name: column_name
type: column
description: Colonne contenant des centimes
- name: scale
type: integer
description: Décimales. Par défaut à 2.

Débogage

Quand les macros se comportent mal, vérifiez target/compiled/ pour le SQL rendu. Exécutez dbt compile --select model_name pendant le développement. Utilisez {{ log("Debug: " ~ my_variable, info=true) }} pour l’inspection à l’exécution. Pour les échecs durs, {{ exceptions.raise_compiler_error("Invalide: " ~ value) }} stoppe la compilation avec un message clair.

Testez les macros via les tests unitaires (dbt 1.8+) ou des modèles de test d’intégration qui vérifient la sortie par rapport aux valeurs attendues. Le SQL compilé est toujours la source de vérité.

Pièges courants

Accolades imbriquées. Les expressions Jinja dans d’autres expressions n’ont pas besoin de doubles accolades : {{ my_macro(var('x')) }} est correct, pas {{ my_macro({{ var('x') }}) }}.

Portée des variables dans les boucles. Les variables définies dans une boucle for ne persistent pas en dehors. Utilisez namespace pour les compteurs qui doivent survivre à la frontière de la boucle :

{% set ns = namespace(total=0) %}
{% for item in items %}
{% set ns.total = ns.total + item %}
{% endfor %}

Gardes execute manquants. Toute macro appelant run_query() ou accédant à graph.nodes nécessite la vérification execute. Sans elle, vous obtenez des erreurs cryptiques au moment du parsing sur des relations inexistantes.

Migration des clés surrogate. Passer de l’ancien surrogate_key() à generate_surrogate_key() change les valeurs de hash pour les lignes avec des nulls, ce qui casse les modèles incrémentaux et les snapshots qui utilisaient l’ancienne clé.