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 :

models/orders.sql
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 :

Le modèle orders dans dbt docs : chaque colonne a une description, les types remontés de Databricks sont integer, string, decimal(10,2) et timestamp, et la colonne Data Tests marque order_id comme unique et not-null, customer_id comme not-null avec un test relationships, et les autres colonnes comme 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
          - 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

L'arborescence du projet dbt : models/ contient customers.sql, orders.sql, orders.yml et stg_orders.sql ; tests/ contient le répertoire généré datacontract_cli/orders/ avec les deux fichiers de contrôle de l'e-mail, à côté du fichier écrit à la main assert_returned_orders_have_amount.sql.
Les tests générés vivent dans leur propre répertoire. Le reste du projet est modifié sur place.

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 :

tests/datacontract_cli/orders/orders__1_0_0__orders__email__length.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.

.github/workflows/dbt.yml
- 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
Une exécution GitHub Actions en échec : l'étape Fail if the dbt project has drifted from the contracts est dépliée et montre git diff ramenant la description modifiée à la main et le data_type FLOAT au DECIMAL(10,2) du contrat, avant de se terminer par le code de sortie 1.
Quelqu'un a modifié le YAML dbt au lieu du contrat. Le build passe au rouge sur ce commit précis, avec un diff qui nomme le champ.

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.

Le Data Contract Orders dans Entropy Data : purpose, usage et limitations, ainsi que le schéma avec la description, le type et l'indicateur required de chaque colonne, et une marque rouge sur status là où la dernière exécution a échoué.
La vue Data Contract Tests dans Entropy Data : un histogramme des contrôles réussis et échoués par exécution sur douze jours, et les contrôles de la dernière exécution nommés selon le libellé du contrat plutôt que selon les noms de nœuds dbt.
Ce que voient les Data Consumers dans Entropy Data : le contrat lui-même, et chaque exécution de ses contrôles dans le temps.

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.