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:
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:
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
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:
-- 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.
- 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 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.
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.