Wissen
dbt mit Data Contracts: synchronisieren statt nachpflegen
Wer Data Contracts in ein dbt-Projekt einführt, pflegt am Ende meist dasselbe Schema zweimal: einmal im Contract, einmal im dbt-YAML. Die beiden laufen sofort auseinander.
datacontract dbt sync erspart dir das Abtippen: Der Befehl schreibt den Contract direkt in ein bestehendes dbt-Projekt hinein.
Dafür brauchst du zwei Befehle:
# alle Data Contracts im aktuellen Verzeichnis finden und
# sicherstellen, dass sie in den dbt-Modell-YAMLs hinterlegt sind
datacontract dbt sync
# alle Tests aus dem Contract mit dbt ausführen und die Ergebnisse ausgeben
datacontract dbt test
Lässt du beide in der CI laufen, ist der Contract kein Dokument mehr, das irgendwer parallel zum Code aktuell halten muss. Er wird zur Single Source of Truth für dein dbt-Projekt.
In diesem Artikel schauen wir uns das an einem einfachen orders-Modell an.
Der Ausgangspunkt
Wir gehen davon aus, dass du schon ein dbt-Projekt hast. Falls du bei null anfängst, schau dir vorher vielleicht unseren Data Product Builder an.
Wir starten hier mit diesem orders-Modell auf Databricks:
select
order_id,
internal_flag,
customer_id,
status,
amount,
ordered_at,
email
from {{ ref('stg_orders') }}
dbt sync braucht keine vorhandenen Properties-Dateien — fehlen sie, werden sie einfach angelegt. In unserem Fall hat aber schon jemand die Spalten dokumentiert:
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
# Internes Ops-Feld, bewusst nicht Teil des Contracts.
- 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
In unserem Beispiel definiert das dbt-Projekt seine Modelle bereits in Properties-Dateien. dbt sync bearbeitet sie direkt (oder legt neue an, wenn es keine gibt).
Wie sagen wir den Consumers unseres dbt-Projekts jetzt verbindlich zu, wie diese Daten aussehen? Genau dafür gibt es Data Contracts: ein eigenständiges, versioniertes Dokument mit Schema, Typen, Qualitätsregeln und den Zuständigkeiten dafür — lesbar auch für Consumers ohne Zugriff auf unser Repo und ohne dbt-Kenntnisse, und gleichzeitig maschinell prüfbar. Legen wir also aus dem, was wir haben, einen Data Contract an.
Einen Data Contract anlegen
Den ersten Contract muss niemand von Hand schreiben. datacontract import dbt liest dbts manifest.json und leitet Spalten, Beschreibungen, Primary Keys, Pflichtfelder und Foreign Keys aus den Tests und Constraints ab, die ohnehin schon im Projekt stehen.
Wir lassen dbt also erst das Manifest bauen und importieren daraus:
dbt parse
datacontract import dbt \
--source target/manifest.json \
--model orders \
--id orders \
--output orders.odcs.yaml
Ab hier gehört der Contract uns. Wir werfen internal_flag raus — ein Ops-Feld, auf das sich Consumers ohnehin nicht verlassen sollten — und ergänzen Details: das Warehouse, in dem die Daten liegen, die vier möglichen status-Werte, präzisere Datentypen, die Währung von amount oder Länge und Format der 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]+$"
Den Contract ins Projekt synchronisieren
Jetzt wäre es verlockend, diese Verfeinerungen von Hand in die dbt-Properties-Dateien zurückzutragen. Das geht besser — probieren wir dbt sync aus:
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.
Was ist passiert? Die Data Contract CLI hat im aktuellen Verzeichnis und darunter nach Data Contracts gesucht (*.odcs.yaml) und dabei sowohl unseren neuen Contract als auch die vorhandene Properties-Datei models/orders.yml gefunden. Alles, was im Contract steht, aber nicht in der Datei, wurde ergänzt — dazu etwas Metainformation.
Zwei Checks — Länge und Muster der E-Mail-Adresse — ließen sich nicht abbilden, ohne zusätzliche Pakete ins dbt-Projekt zu holen. Statt diese Abhängigkeit einzugehen, hat dbt sync dafür Singular Tests in SQL erzeugt; die sehen wir uns gleich an.
Standardmäßig löscht dbt sync nichts aus den Properties-Dateien, was der Data Contract nicht ausdrücklich überschreibt. Das gilt für Kommentare und selbst ergänzte Tests, aber auch für Spalten wie unser internal_flag, das im Contract fehlt. Der Hinweis in der Ausgabe zeigt den Weg: Mit --prune fliegt die Spalte raus.
Schauen wir in die geänderte Properties-Datei. Dort stehen jetzt zum Beispiel die zulässigen Werte für status, die aktualisierte Beschreibung von amount und automatisch erzeugte, lesbare Check-Namen.
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
# Internes Ops-Feld, bewusst nicht Teil des Contracts.
- 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
# Internes Ops-Feld, bewusst nicht Teil des Contracts.
- 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
Ein zweiter Lauf von dbt sync ändert jetzt nichts mehr:
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.
Die Tests, die dabei herauskommen — auch die, die dbt allein nicht abbilden kann
Jede Regel im Contract wird zu einem echten dbt-Test: required zu not_null, primaryKey zu unique, ein Enum zu accepted_values und ein relationships-Eintrag zu dbts relationships-Test, wobei das Ziel als ref() geschrieben wird. Was im Projekt fehlt, legt Sync an; was schon da ist, übernimmt es.
Für Properties wie maxLength und pattern gibt es allerdings keinen generischen dbt-Test. Sie landen deshalb als Singular Tests in 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)
Die Tests ausführen
Jetzt, wo Contract und dbt-Projekt synchron sind, könnten wir die Daten einfach mit dbts eingebautem dbt test prüfen. Mit Data Contracts im Spiel bietet datacontract dbt test aber einen Wrapper mit ein paar Extras: Er läuft gezielt gegen einen oder mehrere Data Contracts, zeigt lesbare Check-Namen an, setzt den Exit-Code passend zum Ergebnis und kann die Ergebnisse exportieren.
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.
Schleus einen ungültigen status-Wert ein, und derselbe Befehl meldet ihn — im Wortlaut des Contracts statt in dem von 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
Damit es auch wirklich gilt
Bis hierher hängt alles daran, dass jemand daran denkt, sync auszuführen. Das ist keine Verbindlichkeit, das ist Disziplin. Übernimmt die CI diese Aufgabe, ist sie nicht mehr optional.
Ein --check-Flag gibt es nicht, und es braucht auch keins: Sync ist idempotent, also ist erneut synchronisieren plus git diff genau diese Prüfung. Hat jemand ein Feld, das dem Contract gehört, direkt im dbt-YAML geändert, schreibt Sync es zurück und das Arbeitsverzeichnis ist nicht mehr sauber. Hat es niemand getan, ändert Sync nichts und der Diff bleibt leer.
- 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
Drift fällt nicht mehr erst Monate später auf, wenn das Dashboard eines Consumers kaputtgeht. Sie ist ein roter Build auf dem Commit, der sie verursacht hat, mit einem Diff, der genau das abweichende Feld zeigt. Das Schema zu ändern heißt ab jetzt, den Contract zu ändern — alles andere kommt nicht durch die CI.
Die Ergebnisse für Data Consumers sichtbar machen
Ein Contract, der nur im Repo eines Teams liegt, ist auch nur für dieses Team sichtbar. Mit --publish geht jeder Testlauf an Entropy Data. Wer die orders-Tabelle nutzen will, sieht dann selbst nach, ob die zugesagten Garantien über die Zeit halten — ganz ohne nachzufragen.
Fazit
Data Contracts in einem von Hand gepflegten dbt-Projekt führen zu Drift zwischen beiden. Die Lösung ist nicht mehr Disziplin, sondern die zweite Kopie abzuschaffen: Das Schema steht im Contract, wird ins Projekt synchronisiert, und die CI lehnt jeden Commit ab, bei dem beides auseinanderläuft. Dafür bekommst du ein dbt-Projekt, das weiterhin deins ist — mit Garantien, die für Consumers nachvollziehbar sind.
Melde dich kostenlos bei Entropy Data an, oder erkunde die klickbare Demo.