Les exposures dbt générées depuis le TMDL
Le graphe de dépendances s’arrête à la dernière table, alors que la question posée en réunion porte toujours sur ce qui vient après. Un dépôt unique et un format texte suffisent à le prolonger : à condition de générer, jamais d’écrire à la main.
La question revient à chaque évolution : si je retire cette colonne, qu’est-ce qui casse. L’outil de transformation répond pour ses propres modèles, avec précision, et s’arrête à la dernière table. Ce qui la lit ensuite (un rapport, une mesure, un tableau de bord ouvert chaque lundi par une direction) n’existe pas dans son graphe. La réponse honnête est donc « je ne sais pas », et la réponse donnée est « je crois que non ».
Les exposures existent précisément pour cela : déclarer ce qui consomme un modèle sans être produit par lui. Elles ont une réputation méritée d’inutilité, et la raison en est simple : écrites à la main, elles sont périmées en un mois. Quelqu’un ajoute une mesure, personne ne modifie le fichier, et le graphe ment désormais avec l’autorité d’un graphe. Une lignée fausse est pire qu’une lignée absente, parce qu’on s’en sert.
Tant que le modèle sémantique est un fichier binaire posé sur l’espace personnel de quelqu’un, il n’y a rien à analyser et la discussion s’arrête là. Depuis qu’il s’écrit en texte, il devient une source comme une autre, et s’il vit dans le même dépôt que le projet de transformation, un script peut lire les deux dans la même passe. C’est la seule condition, et elle est structurelle plutôt que technique.
warehouse/
├── dbt/
│ ├── models/marts/fct_sales.sql
│ └── models/marts/_marts.yml # generated exposures land here
└── semantic/
└── Sales.SemanticModel/
└── definition/tables/Sales.tmdlUne mesure porte tout ce qu’il faut : son nom d’affichage, son expression, et la description que l’auteur a bien voulu écrire. Les tables qu’elle interroge se lisent dans l’expression elle-même.
/// Revenue excluding tax, at invoice date. Excludes cancelled orders.
measure 'Revenue ex tax' =
CALCULATE (
SUMX ( Sales, Sales[quantity] * Sales[unit_price] ),
Sales[status] <> "cancelled"
)
formatString: #,##0 €
displayFolder: Measures\SalesLe script tient en une page : lire les mesures, retrouver les tables citées dans l’expression, écrire un fichier d’exposures. Deux contraintes du schéma décident de la forme du résultat, et les découvrir à l’exécution coûte une heure chacune.
import re
from pathlib import Path
import yaml
MEASURE = re.compile(
r"(?:^///(?P<doc>.*)$
)?" # the /// description line, when there is one
r"^s*measures+'(?P<name>[^']+)'s*=s*(?P<dax>.*?)(?=^s*(?:measure|table|Z))",
re.M | re.S,
)
def slug(label: str) -> str:
# An exposure name accepts letters, digits and underscores only, so a measure
# called 'Revenue ex tax' cannot be one. The real name goes to label.
return re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_")
def exposures(tmdl_dir: Path, models: set[str]) -> list[dict]:
found = []
for path in tmdl_dir.rglob("*.tmdl"):
for m in MEASURE.finditer(path.read_text(encoding="utf-8")):
dax = m.group("dax").strip()
# Tables cited in the expression, kept only when dbt actually builds one
# of that name — anything else is a calculated table, not a dependency.
cited = {t.lower() for t in re.findall(r"([A-Za-z_]w*)s*[", dax)}
depends = sorted(cited & models)
if not depends:
continue
found.append({
"name": slug(m.group("name")),
"label": m.group("name"),
"type": "dashboard",
# description is markdown, which is the whole trick: a fenced block
# puts the DAX itself into the generated documentation site.
"description": f"{(m.group('doc') or '').strip()}
```dax
{dax}
```",
"depends_on": [f"ref('{d}')" for d in depends],
"config": {"meta": {"source_file": str(path)}},
})
return foundLe premier piège est que le nom d’une exposure n’accepte que des lettres, des chiffres et des tirets bas. Une mesure nommée « Revenue ex tax » ne peut donc pas en porter le nom : il faut le translittérer et mettre le nom réel dans `label`, sans quoi la génération échoue sur la moitié du modèle.
Le second est en réalité l’intérêt de la manœuvre. Le champ `description` accepte du markdown, donc un bloc de code, et c’est par là que l’expression DAX entre dans le site de documentation généré. Personne n’a rien écrit ; la documentation d’une mesure est sa propre définition, à jour par construction, à côté du modèle qui l’alimente.
exposures:
- name: revenue_ex_tax
label: Revenue ex tax
type: dashboard
description: |
Revenue excluding tax, at invoice date. Excludes cancelled orders.
```dax
CALCULATE (
SUMX ( Sales, Sales[quantity] * Sales[unit_price] ),
Sales[status] <> "cancelled"
)
```
depends_on:
- ref('fct_sales')
config:
meta:
source_file: semantic/Sales.SemanticModel/definition/tables/Sales.tmdlLa génération tourne dans la chaîne d’intégration et le fichier produit est versionné. La conséquence n’est pas la documentation, qui est un effet secondaire agréable : c’est que la pull request qui retire une colonne fait apparaître, dans son propre diff, la disparition des exposures qui en dépendaient. La question « qu’est-ce qui casse » cesse d’être posée en réunion parce qu’elle est déjà répondue dans la relecture.
L’analyse de l’expression par expression régulière est grossière et je l’assume : elle retrouve les tables citées, elle ne comprend pas le DAX. Une mesure qui en appelle une autre sans nommer de table produit une exposure sans dépendance, donc rien du tout : le script les ignore silencieusement, et c’est le défaut à surveiller. Un analyseur syntaxique correct existe, il coûte bien plus qu’une page, et je ne l’ai pas jugé rentable à cette échelle.
Je ne dis rien non plus du sens inverse, qui serait de pousser les descriptions des modèles vers les mesures plutôt que l’inverse. C’est faisable et je m’en méfie : cela ferait d’un fichier généré la source d’un fichier relu en revue, et le jour où les deux divergent, personne ne sait plus lequel fait foi.