Conocimiento

dbt con Data Contracts: sincronizar en vez de copiar a mano

Cuando metes Data Contracts en un proyecto dbt, acabas manteniendo el mismo esquema dos veces: una en el contrato y otra en el YAML de dbt. Los dos empiezan a separarse de inmediato. datacontract dbt sync te ahorra esa copia: el comando escribe el contrato directamente dentro de un proyecto dbt existente.

Con dos comandos es suficiente:

# buscar todos los Data Contracts del directorio actual y
# comprobar que estén declarados en los YAML de los modelos dbt
datacontract dbt sync

# ejecutar con dbt todos los tests del contrato y mostrar los resultados
datacontract dbt test

En cuanto estos dos comandos corren en CI, el contrato deja de ser un documento que alguien tiene que mantener al día en paralelo al código. Pasa a ser la fuente de verdad de tu proyecto dbt. En este artículo lo vemos sobre un modelo orders muy sencillo.

El punto de partida

Damos por hecho que ya tienes un proyecto dbt montado. Si empiezas desde cero, échale antes un vistazo a nuestro Data Product Builder.

Aquí partimos de este modelo orders en Databricks:

models/orders.sql
select
    order_id,
    internal_flag,
    customer_id,
    status,
    amount,
    ordered_at,
    email
from {{ ref('stg_orders') }}

dbt sync no necesita que existan ficheros de propiedades: si faltan, simplemente los crea. En nuestro caso, resulta que alguien ya documentó las columnas:

El modelo orders en dbt docs: cada columna tiene una descripción, los tipos que llegan de Databricks son integer, string, decimal(10,2) y timestamp, y la columna Data Tests marca order_id como unique y not-null, customer_id como not-null con un test relationships, y el resto de columnas como 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
      # Campo interno de ops, deliberadamente fuera del contrato.
      - 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

En nuestro ejemplo, el proyecto dbt ya define sus modelos en ficheros de propiedades. dbt sync los edita directamente (o crea nuevos si no existen).

¿Y cómo garantizamos ahora a los consumidores de nuestro proyecto dbt la forma de estos datos? Para eso están los Data Contracts: un documento independiente y versionado que fija el esquema, los tipos, las reglas de calidad y quién responde de ellas; legible para un consumidor que no tiene acceso a nuestro repositorio ni motivo para aprender dbt, y verificable por una máquina. Empecemos creando un Data Contract a partir de lo que ya tenemos.

Crear un Data Contract

Nadie tiene por qué escribir el primer contrato a mano. datacontract import dbt lee el manifest.json de dbt y deduce las columnas, las descripciones, las claves primarias, los campos obligatorios y las claves ajenas a partir de los tests y las restricciones que ya hay en el proyecto. Así que primero dejamos que dbt genere el manifiesto y luego importamos de él:

dbt parse

datacontract import dbt \
  --source target/manifest.json \
  --model orders \
  --id orders \
  --output orders.odcs.yaml

A partir de aquí el contrato es nuestro. Quitamos internal_flag, un campo de ops del que los consumidores no deberían depender, y afinamos el resto: el warehouse donde viven los datos, los cuatro valores posibles de status, tipos más precisos, la moneda de amount o la longitud y el formato del 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]+$"

Sincronizar el contrato en el proyecto

Ahora sería tentador llevar esos retoques a mano de vuelta a los ficheros de propiedades de dbt. Se puede hacer mejor: probemos 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.

¿Qué ha pasado? La Data Contract CLI buscó Data Contracts en el directorio actual y sus subdirectorios (*.odcs.yaml) y encontró tanto nuestro contrato recién creado como el fichero de propiedades existente models/orders.yml. Todo lo que estaba en el contrato pero no en el fichero se añadió, junto con algo de metainformación.

Dos de las comprobaciones —la longitud y el patrón del correo— no se podían expresar sin añadir paquetes externos al proyecto dbt. Para evitar esa dependencia, dbt sync generó tests singulares en SQL; los vemos enseguida.

Por defecto, dbt sync no borra nada de los ficheros de propiedades que el Data Contract no sobrescriba explícitamente. Eso vale para los comentarios y los tests añadidos a mano, pero también para columnas como nuestro internal_flag, que falta en el contrato. El aviso de la consola señala el camino: con la opción --prune esa columna desaparece.

Miremos el fichero de propiedades modificado. Ahí están ahora los valores admitidos de status, la descripción actualizada de amount y nombres de comprobación legibles generados automáticamente.

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
      # Campo interno de ops, deliberadamente fuera del contrato.
      - 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
      # Campo interno de ops, deliberadamente fuera del contrato.
      - 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

Una segunda ejecución de dbt sync ya no cambia nada:

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.

Los tests que obtenemos, incluidos los que dbt por sí solo no sabe expresar

El árbol del proyecto dbt: models/ contiene customers.sql, orders.sql, orders.yml y stg_orders.sql; tests/ contiene el directorio generado datacontract_cli/orders/ con los dos ficheros de comprobación del correo, junto al assert_returned_orders_have_amount.sql escrito a mano.
Los tests generados viven en su propio directorio. El resto del proyecto se modifica en su sitio.

Cada regla del contrato se convierte en un test de dbt de verdad: required pasa a not_null, primaryKey a unique, un enum a accepted_values y una entrada relationships al test relationships de dbt, con el destino reescrito como ref(). Sync crea los que faltan en el proyecto y adopta los que ya están.

En cambio, propiedades como maxLength y pattern no tienen un test genérico en dbt. Por eso se expresan como tests singulares 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)

Ejecutar los tests

Ahora que el contrato y el proyecto dbt están sincronizados, podríamos comprobar la forma de los datos con el dbt test de siempre. Pero trabajando con Data Contracts, datacontract dbt test añade unas cuantas ventajas: se ciñe a uno o varios Data Contracts, muestra nombres de comprobación legibles, ajusta su código de salida al resultado y puede exportar los resultados.

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.

Cuela un valor de status inválido y el mismo comando lo señala, con las palabras del contrato en lugar de las 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

Para que sea verdad, y no solo una buena intención

Hasta aquí todo depende de que alguien se acuerde de ejecutar sync. Eso no es un compromiso, es disciplina. Si le pasamos el trabajo a la CI, deja de ser opcional.

No hay una opción --check, ni hace falta: sync es idempotente, así que volver a sincronizar y hacer git diff es la comprobación. Si alguien editó directamente en el YAML de dbt un campo que pertenece al contrato, sync lo reescribe y el árbol de trabajo deja de estar limpio. Si nadie lo hizo, sync no cambia nada y el diff queda vacío.

.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
Una ejecución de GitHub Actions fallida: el paso Fail if the dbt project has drifted from the contracts está desplegado y muestra cómo git diff devuelve la descripción editada a mano y el data_type FLOAT al DECIMAL(10,2) del contrato, terminando con el código de salida 1.
Alguien editó el YAML de dbt en lugar del contrato. El build se pone rojo en ese commit, con un diff que nombra el campo.

La deriva ya no se descubre meses después, cuando se rompe el cuadro de mando de un consumidor. Es un build rojo, en el commit que la provocó, con un diff que señala exactamente el campo que discrepa. Cambiar el esquema significa ahora cambiar el contrato: cualquier otra cosa no sobrevive a la CI.

Hacer visibles los resultados para los Data Consumers

Un contrato que solo vive en el repositorio de un equipo solo lo ve ese equipo. Con --publish, cada ejecución de tests viaja a Entropy Data. Quien quiera usar la tabla orders puede comprobar por su cuenta si las garantías prometidas se sostienen con el tiempo, sin tener que preguntar.

El Data Contract Orders en Entropy Data: purpose, usage y limitations, más el esquema con la descripción, el tipo y el indicador required de cada columna, con una marca roja en status, donde falló la última ejecución.
La vista Data Contract Tests en Entropy Data: un gráfico de barras con las comprobaciones superadas y fallidas por ejecución a lo largo de doce días, y las comprobaciones de la última ejecución nombradas según el texto del contrato en vez de los nombres de nodo de dbt.
Lo que ven los Data Consumers en Entropy Data: el contrato en sí y cada ejecución de sus comprobaciones a lo largo del tiempo.

En resumen

Meter Data Contracts en un proyecto dbt mantenido a mano acaba en deriva entre ambos. El remedio no es más disciplina, sino eliminar la segunda copia: el esquema vive en el contrato, se sincroniza en el proyecto y la CI rechaza cualquier commit en el que los dos no coincidan. A cambio te queda un proyecto dbt que sigue siendo tuyo, pero con garantías transparentes para los consumidores.

Crea una cuenta gratis en Entropy Data, o explora la demo interactiva.