Connaissances
dbt et les Data Contracts : synchroniser au lieu de recopier
Quand on introduit des Data Contracts dans un projet dbt, on finit presque toujours par maintenir le même schéma deux fois : une fois dans le contrat, une fois dans le YAML dbt. Les deux divergent aussitôt.
datacontract dbt sync vous évite cette recopie : la commande écrit le contrat directement dans un projet dbt existant.
Deux commandes suffisent :
# trouver tous les Data Contracts du répertoire courant et
# vérifier qu'ils sont bien déclarés dans les YAML des modèles dbt
datacontract dbt sync
# exécuter tous les tests du contrat via dbt et afficher les résultats
datacontract dbt test
Une fois ces deux commandes en CI, le contrat cesse d'être un document que quelqu'un doit tenir à jour en parallèle du code. Il devient la source de vérité de votre projet dbt.
Dans cet article, nous verrons comment cela fonctionne sur un modèle orders tout simple.
Le point de départ
Nous partons du principe que vous avez déjà un projet dbt. Si vous démarrez de zéro, jetez plutôt un œil d'abord à notre Data Product Builder.
Ici, nous partons de ce modèle orders sur Databricks :
select
order_id,
internal_flag,
customer_id,
status,
amount,
ordered_at,
email
from {{ ref('stg_orders') }}
dbt sync n'exige pas que les fichiers de propriétés existent : s'ils manquent, ils sont simplement créés. Dans notre cas, quelqu'un a toutefois déjà documenté les colonnes :
version: 2
models:
- name: orders
description: One row per order.
columns:
- name: order_id
description: Unique identifier for the order.
data_tests:
- unique
- not_null
# Champ interne des ops, volontairement hors du contrat.
- name: internal_flag
description: Fulfilment queue marker used by the ops team.
data_tests:
- not_null
- name: customer_id
description: Identifier of the customer who placed the order.
data_tests:
- not_null
- relationships:
to: ref('customers')
field: customer_id
- name: status
description: Current status of the order.
data_tests:
- not_null
- name: amount
description: Total order amount.
data_tests:
- not_null
- name: ordered_at
description: Timestamp the order was placed.
data_tests:
- not_null
- name: email
description: Customer email address captured at order time.
data_tests:
- not_null
Dans notre exemple, le projet dbt définit déjà ses modèles dans des fichiers de propriétés. dbt sync les modifie directement (ou en crée de nouveaux s'il n'y en a pas).
Comment garantir maintenant aux consommateurs de notre projet dbt la forme de ces données ? C'est précisément à cela que servent les Data Contracts : un document autonome et versionné qui fixe le schéma, les types, les règles de qualité et les responsabilités associées — lisible par un consommateur qui n'a pas accès à notre dépôt et aucune raison d'apprendre dbt, et vérifiable par une machine. Commençons par créer un Data Contract à partir de l'existant.
Créer un Data Contract
Personne n'a besoin d'écrire le premier contrat à la main. datacontract import dbt lit le manifest.json de dbt et déduit les colonnes, descriptions, clés primaires, champs obligatoires et clés étrangères des tests et contraintes déjà présents dans le projet.
On laisse donc dbt produire le manifeste, puis on importe depuis celui-ci :
dbt parse
datacontract import dbt \
--source target/manifest.json \
--model orders \
--id orders \
--output orders.odcs.yaml
À partir de là, le contrat nous appartient. Nous retirons internal_flag — un champ des ops sur lequel les consommateurs n'ont pas à s'appuyer — et précisons le reste : l'entrepôt où vivent les données, les quatre valeurs possibles de status, des types plus précis, la devise du champ amount, ou encore la longueur et le format de l'email.
version: 1.0.0
kind: DataContract
apiVersion: v3.1.0
id: orders
name: orders_demo
status: draft
schema:
- name: orders
physicalType: view
description: One row per order.
logicalType: object
physicalName: orders
properties:
- name: order_id
description: Unique identifier for the order.
primaryKey: true
primaryKeyPosition: 1
logicalType: string
required: true
unique: true
- name: internal_flag
description: Fulfilment queue marker used by the ops team.
logicalType: string
required: true
- name: customer_id
description: Identifier of the customer who placed the order.
customProperties:
- property: references
value: customers.customer_id
logicalType: string
required: true
- name: status
description: Current status of the order.
logicalType: string
required: true
- name: amount
description: Total order amount.
logicalType: string
required: true
- name: ordered_at
description: Timestamp the order was placed.
logicalType: string
required: true
- name: email
description: Customer email address captured at order time.
logicalType: string
required: true
customProperties:
- property: dbt_version
value: 1.11.9
version: 1.0.0
kind: DataContract
apiVersion: v3.1.0
id: orders
name: Orders
status: active
description:
purpose: One row per customer order, from checkout through fulfilment.
usage: Order volume and revenue reporting, and any downstream model that needs order state.
limitations: Rebuilt nightly, so not suitable for real-time use. Excludes baskets that never became orders.
servers:
- server: production
type: databricks
catalog: playground
schema: orders_demo
schema:
- name: orders
physicalName: orders
description: One row per order.
properties:
- name: order_id
description: Unique identifier for the order.
logicalType: integer
primaryKey: true
required: true
- name: customer_id
description: Identifier of the customer who placed the order.
logicalType: integer
required: true
relationships:
- to: customers.customer_id
- name: status
description: Current status of the order.
logicalType: string
required: true
customProperties:
- property: enum
value:
- placed
- shipped
- completed
- returned
- name: amount
description: Total order amount in USD.
logicalType: number
physicalType: DECIMAL(10,2)
required: true
- name: ordered_at
description: Timestamp the order was placed.
logicalType: timestamp
required: true
- name: email
description: Customer email address captured at order time.
logicalType: string
required: true
logicalTypeOptions:
maxLength: 255
pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"
Synchroniser le contrat dans le projet
Il serait maintenant tentant de reporter ces précisions à la main dans les fichiers de propriétés dbt. On peut faire mieux — essayons dbt sync :
datacontract dbt sync
orders.odcs.yaml: Synced 1 model: updated 1 YAML file, wrote 2 singular SQL tests.
~ models/orders.yml
+ tests/datacontract_cli/orders/orders__1_0_0__orders__email__length.sql
+ tests/datacontract_cli/orders/orders__1_0_0__orders__email__pattern.sql
orders.odcs.yaml: 1 item in the project is not declared in the contract; pass --prune to remove:
- orders: column `internal_flag`
Run `datacontract dbt test` to execute the generated tests.
Que s'est-il passé ? La Data Contract CLI a cherché des Data Contracts dans le répertoire courant et ses sous-répertoires (*.odcs.yaml) et y a trouvé aussi bien notre nouveau contrat que le fichier de propriétés existant models/orders.yml. Tout ce qui figurait dans le contrat mais pas dans le fichier a été ajouté, accompagné de quelques métadonnées.
Deux contrôles — la longueur et le format de l'adresse e-mail — n'étaient pas exprimables sans ajouter des paquets externes au projet dbt. Plutôt que de créer cette dépendance, dbt sync a généré des tests singuliers en SQL ; nous les verrons juste après.
Par défaut, dbt sync ne supprime rien des fichiers de propriétés que le Data Contract ne remplace pas explicitement. Cela vaut pour les commentaires et les tests ajoutés à la main, mais aussi pour des colonnes comme notre internal_flag, absente du contrat. Le message affiché dans la console indique la marche à suivre : l'option --prune fait disparaître la colonne.
Regardons le fichier de propriétés modifié. On y trouve désormais les valeurs autorisées de status, la description mise à jour d'amount, ou encore des noms de contrôles lisibles générés automatiquement.
version: 2
models:
- name: orders
description: One row per order.
columns:
- name: order_id
description: Unique identifier for the order.
data_tests:
- unique
- not_null
# Champ interne des ops, volontairement hors du contrat.
- name: internal_flag
description: Fulfilment queue marker used by the ops team.
data_tests:
- not_null
- name: customer_id
description: Identifier of the customer who placed the order.
data_tests:
- not_null
- relationships:
to: ref('customers')
field: customer_id
- name: status
description: Current status of the order.
data_tests:
- not_null
- name: amount
description: Total order amount.
data_tests:
- not_null
- name: ordered_at
description: Timestamp the order was placed.
data_tests:
- not_null
- name: email
description: Customer email address captured at order time.
data_tests:
- not_null
version: 2
models:
- name: orders
description: One row per order.
columns:
- name: order_id
description: Unique identifier for the order.
data_tests:
- unique:
description: Check that field order_id has no duplicate values
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__order_id__field_unique
contract_versions:
- 1.0.0
generated: false
- not_null:
description: Check that field order_id has no missing values
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__order_id__field_required
contract_versions:
- 1.0.0
generated: false
data_type: INT
# Champ interne des ops, volontairement hors du contrat.
- name: internal_flag
description: Fulfilment queue marker used by the ops team.
data_tests:
- not_null
- name: customer_id
description: Identifier of the customer who placed the order.
data_tests:
- not_null:
description: Check that field customer_id has no missing values
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__customer_id__field_required
contract_versions:
- 1.0.0
generated: false
- relationships:
to: ref('customers')
field: customer_id
description: Check that field customer_id references ref('customers').customer_id
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__customer_id__field_relationships
contract_versions:
- 1.0.0
generated: false
data_type: INT
- name: status
description: Current status of the order.
data_tests:
- not_null:
description: Check that field status has no missing values
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__status__field_required
contract_versions:
- 1.0.0
generated: false
- accepted_values:
values:
- placed
- shipped
- completed
- returned
config:
meta:
datacontract_cli:
check: orders__status__field_enum
include_in_tests: true
contract_versions:
- 1.0.0
generated: true
description: Check that field status only contains enum values ['placed', 'shipped', 'completed', 'returned']
data_type: STRING
- name: amount
description: Total order amount in USD.
data_tests:
- not_null:
description: Check that field amount has no missing values
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__amount__field_required
contract_versions:
- 1.0.0
generated: false
data_type: DECIMAL(10,2)
- name: ordered_at
description: Timestamp the order was placed.
data_tests:
- not_null:
description: Check that field ordered_at has no missing values
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__ordered_at__field_required
contract_versions:
- 1.0.0
generated: false
data_type: TIMESTAMP
- name: email
description: Customer email address captured at order time.
data_tests:
- not_null:
description: Check that field email has no missing values
config:
meta:
datacontract_cli:
include_in_tests: true
check: orders__email__field_required
contract_versions:
- 1.0.0
generated: false
data_type: STRING
config:
meta:
datacontract_cli:
contract_id: orders
Une deuxième exécution de dbt sync ne change désormais plus rien :
orders.odcs.yaml: Synced 1 model: updated 0 YAML files.
orders.odcs.yaml: 1 item in the project is not declared in the contract; pass --prune to remove:
- orders: column `internal_flag`
Run `datacontract dbt test` to execute the generated tests.
Les tests obtenus, y compris ceux que dbt seul ne sait pas exprimer
Chaque règle du contrat devient un vrai test dbt : required donne not_null, primaryKey donne unique, un enum donne accepted_values, et une entrée relationships donne le test relationships de dbt, la cible étant réécrite en ref(). Sync crée ce qui manque au projet et reprend ce qui existe déjà.
En revanche, des propriétés comme maxLength et pattern n'ont pas de test dbt générique. Elles sont donc exprimées sous forme de tests singuliers en SQL :
-- AUTO-GENERATED by `datacontract dbt sync`. Do not edit.
-- Source contract: orders@1.0.0 (model: orders, check: email__length)
{{ config(meta={"datacontract_cli": {"check": "orders__email__field_length", "contract_versions": ["1.0.0"], "generated": true, "include_in_tests": true, "model": "orders", "field": "email", "description": "Check that field email has a length of at most 255"}}) }}
SELECT *
FROM {{ ref('orders') }}
WHERE "email" IS NOT NULL
AND (LENGTH("email") > 255)
Exécuter les tests
Maintenant que le contrat et le projet dbt sont synchronisés, nous pourrions vérifier la forme des données avec le dbt test natif. Mais dès lors que l'on travaille avec des Data Contracts, datacontract dbt test ajoute quelques atouts : il cible un ou plusieurs Data Contracts, affiche des noms de contrôles lisibles, adapte son code de retour au résultat et sait exporter les résultats.
datacontract dbt test orders.odcs.yaml
Contract orders@1.0.0 — /Users/you/orders-demo/orders.odcs.yaml
╭────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────┬─────────╮
│ Result │ Check │ Field │ Details │
├────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────┼─────────┤
│ passed │ Check that field amount has no missing values │ amount │ │
│ passed │ Check that field customer_id has no missing values │ customer_id │ │
│ passed │ Check that field customer_id references ref('customers').customer_id │ customer_id │ │
│ passed │ Check that field email has no missing values │ email │ │
│ passed │ Check that field email has a length of at most 255 │ email │ │
│ passed │ Check that field email matches regex pattern ^[^@\s]+@[^@\s]+\.[^@\s]+$ │ email │ │
│ passed │ Check that field order_id has no missing values │ order_id │ │
│ passed │ Check that field order_id has no duplicate values │ order_id │ │
│ passed │ Check that field ordered_at has no missing values │ ordered_at │ │
│ passed │ Check that field status only contains enum values ['placed', 'shipped', 'completed', 'returned'] │ status │ │
│ passed │ Check that field status has no missing values │ status │ │
╰────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴─────────────┴─────────╯
🟢 dbt tests passed. Ran 11 tests. Took 0.00406 seconds.
Glissez une valeur de status invalide dans les données : la même commande la signale, avec les mots du contrat plutôt que ceux de dbt :
Contract orders@1.0.0 — /Users/you/orders-demo/orders.odcs.yaml
╭────────┬───────────────────────────────────────────────────────┬─────────────┬────────────────────────────────────────────────────╮
│ Result │ Check │ Field │ Details │
├────────┼───────────────────────────────────────────────────────┼─────────────┼────────────────────────────────────────────────────┤
│ failed │ Check that field status only contains enum values │ status │ failures=1 | Got 1 result, configured to fail if │
│ │ ['placed', 'shipped', 'completed', 'returned'] │ │ != 0 │
│ passed │ Check that field amount has no missing values │ amount │ │
│ passed │ Check that field customer_id has no missing values │ customer_id │ │
│ passed │ Check that field customer_id references │ customer_id │ │
│ │ ref('customers').customer_id │ │ │
│ passed │ Check that field email has no missing values │ email │ │
│ passed │ Check that field email has a length of at most 255 │ email │ │
│ passed │ Check that field email matches regex pattern │ email │ │
│ │ ^[^@\s]+@[^@\s]+\.[^@\s]+$ │ │ │
│ passed │ Check that field order_id has no missing values │ order_id │ │
│ passed │ Check that field order_id has no duplicate values │ order_id │ │
│ passed │ Check that field ordered_at has no missing values │ ordered_at │ │
│ passed │ Check that field status has no missing values │ status │ │
╰────────┴───────────────────────────────────────────────────────┴─────────────┴────────────────────────────────────────────────────╯
🔴 dbt tests failed, found the following errors:
1) status Check that field status only contains enum values ['placed', 'shipped', 'completed', 'returned']: failures=1 | Got 1
result, configured to fail if != 0
Pour que ce soit vraiment vrai, et pas seulement souhaité
Jusqu'ici, tout repose sur le fait que quelqu'un pense à lancer sync. Ce n'est pas un engagement, c'est de la discipline. Confiée à la CI, la tâche cesse d'être optionnelle.
Il n'existe pas d'option --check, et il n'en faut pas : sync est idempotent, donc resynchroniser puis faire un git diff est le contrôle. Si quelqu'un a modifié directement dans le YAML dbt un champ qui appartient au contrat, sync le réécrit et l'arbre de travail n'est plus propre. Sinon, sync ne change rien et le diff reste vide.
- name: Sync data contracts into the dbt project
run: datacontract dbt sync contracts/*.odcs.yaml
- name: Fail if the dbt project has drifted from the contracts
run: git diff --exit-code
- name: Run the contract's tests
run: |
datacontract dbt test contracts/*.odcs.yaml \
--publish https://api.entropy-data.com/api/test-results
La dérive ne se découvre plus des mois après, quand le tableau de bord d'un consommateur casse. C'est un build rouge, sur le commit qui l'a causée, avec un diff qui désigne exactement le champ divergent. Changer le schéma veut désormais dire changer le contrat : tout le reste ne passe pas la CI.
Rendre les résultats visibles pour les Data Consumers
Un contrat qui ne vit que dans le dépôt d'une équipe n'est visible que par cette équipe. Avec --publish, chaque exécution de tests part vers Entropy Data. Qui veut utiliser la table orders vérifie alors par lui-même si les garanties promises tiennent dans la durée, sans avoir à demander.
En résumé
Des Data Contracts posés sur un projet dbt maintenu à la main finissent par diverger de celui-ci. Le remède n'est pas plus de discipline, mais la suppression de la seconde copie : le schéma vit dans le contrat, il est synchronisé dans le projet, et la CI refuse tout commit où les deux ne concordent pas. Vous obtenez en échange un projet dbt qui reste le vôtre, mais dont les garanties sont lisibles par les consommateurs.
Créez un compte gratuit sur Entropy Data, ou explorez la démo cliquable.