Doc blocks: переиспользование descriptions через doc(‘name’)
В прошлом уроке мы написали production-grade description для одной модели. Но в реальных проектах одни и те же термины встречаются десятки раз: customer_id, revenue, churn, lifetime_value. Каждый раз дублировать описание — recipe для расхождений. Через 6 месяцев customer_id в одной модели описан как «PK от raw.customers», в другой — «уникальный идентификатор клиента», в третьей — TODO.
Решение — doc blocks: переиспользуемые блоки документации в .md файлах + {{ doc('name') }} из YAML. Junior-уровень синтаксис уже разбирался в dbt-i; здесь — про CI gates, cross-project Mesh, {% raw %} escaping для метасимволов, и orphaned doc detection.
Базовый синтаксис {% docs name %}...{% enddocs %} + ссылка {{ doc('name') }} и YAML quoting trap (внешние ≠ внутренним) разбирались в dbt-i/14/02. Здесь — middle: CI gate check_doc_blocks.py, cross-project, escaping, orphaned detection.
Recap в одном примере
{# models/_glossary.md #}
{% docs revenue %}
**Revenue** — SUM(order_total) FILTER (status = 'completed'). NUMERIC(12, 2). USD.
Refunds не вычитаются — см. `net_revenue`.
{% enddocs %}
# models/_models.yml
- name: customer_metrics
columns:
- name: revenue
description: '{{ doc("revenue") }}' # внешние ≠ внутренним кавычкам
YAML quoting: '{{ doc("name") }}' или "{{ doc('name') }}" или block scalar |. Без кавычек YAML парсит {{ как Flow-map start и падает.
Где лежат doc blocks
dbt по умолчанию ищет doc blocks в:
# dbt_project.yml
docs-paths: ["docs"]
или (более распространённое) — в самих models/ рядом с моделями:
# dbt_project.yml
# по умолчанию dbt видит docs во всех source-paths
# поэтому .md файлы в models/ автоматически подхватываются
Типичная организация:
models/
_glossary.md ← общие термины
marts/
_marts__docs.md ← marts-specific docs
finance/
_finance__docs.md ← finance-specific docs
revenue_metrics.sql
_finance__models.yml
Convention: _<context>__docs.md или _glossary.md. Префикс _ группирует все YAML/MD в IDE.
Полный пример glossary
models/_glossary.md:
{% docs customer_id %}
**customer_id** — primary key dimension `dim_customers`. Originated from
`raw.customers.id` (Postgres app DB). Уникальный для каждого клиента.
Используется как FK в:
- `fct_orders.customer_id`
- `customer_metrics.customer_id`
- `customer_segments.customer_id`
**Тип**: BIGINT. **Constraints**: PK, NOT NULL.
{% enddocs %}
{% docs revenue %}
**Revenue** — суммарная сумма заказов клиента (status = 'completed') за указанный
период. Формула: `SUM(order_total) FILTER (WHERE status = 'completed')`.
**Валюта**: USD. **Тип**: NUMERIC(12, 2). **Edge case**: при 0 заказов -> 0.
**Refunds** — не вычитаются. Для net revenue использовать `net_revenue` колонку.
{% enddocs %}
{% docs net_revenue %}
**Net revenue** = revenue − refunds. Формула: `SUM(order_total - COALESCE(refund_amount, 0))`.
Отрицательный, если refunds > orders за период. Используется для accurate finance reporting.
**Валюта**: USD. **Тип**: NUMERIC(12, 2).
{% enddocs %}
{% docs ltv %}
**Lifetime value (LTV)** — суммарная revenue от клиента за всю историю (от первого
заказа до сегодня). Не учитывает refunds (см. `net_ltv` для net version).
**Формула**: `SUM(o.order_total) FILTER (WHERE o.status = 'completed')`.
**Использование**: classification сегментов, ML feature, marketing budgeting.
**Тип**: NUMERIC(12, 2).
{% enddocs %}
{% docs churn %}
**Churn** — событие потери клиента. В нашем определении: клиент не сделал заказа
за последние 90 дней.
**Формула**: `last_order_date < CURRENT_DATE - INTERVAL '90 day'`.
**Альтернативные определения** (для разных контекстов):
- **Soft churn**: 30-89 дней — клиент в риске
- **Hard churn**: 90+ дней — отток
- **Revenue churn**: monthly recurring revenue лост, не клиент (для SaaS-моделей)
В этом проекте используется **hard churn** (90 day).
{% enddocs %}
{% docs status_categories %}
Возможные значения колонки `status` в `fct_orders`:
- `completed` — заказ доставлен, оплачен
- `pending` — заказ создан, не оплачен
- `cancelled` — отменён клиентом до доставки
- `refunded` — возвращён после доставки
Изменения в этом списке требуют:
1. Обновление test `accepted_values` в `_models.yml`
2. Обновление CASE-логики в `customer_metrics` и downstream marts
3. Уведомление data-консумеров через #data-channel
{% enddocs %}
И YAML, использующий эти doc blocks:
version: 2
models:
- name: customer_metrics
description: |
**Grain**: один клиент.
**Бизнес**: KPI для customer success.
**Обновление**: ежедневно 06:00 UTC.
**Owner**: data-team@.
columns:
- name: customer_id
description: '{{ doc("customer_id") }}'
data_tests: [unique, not_null]
- name: revenue
description: '{{ doc("revenue") }}'
- name: ltv
description: '{{ doc("ltv") }}'
- name: is_churned
description: |
Boolean flag: TRUE если клиент в hard churn.
**Определение churn**: {{ doc("churn") }}
- name: fct_orders
columns:
- name: customer_id
description: '{{ doc("customer_id") }}' # тот же блок что в customer_metrics
- name: status
description: '{{ doc("status_categories") }}'
data_tests:
- accepted_values:
values: ['completed', 'pending', 'cancelled', 'refunded']
Когда поменяется определение revenue (например, добавим refunds в формулу), правишь один doc block в _glossary.md. Все YAML, ссылающиеся через {{ doc("revenue") }}, подхватят изменение.
CI gate: orphaned blocks и broken refs
Без CI doc blocks дрейфуют: typo в {{ doc('revneue') }} -> dbt parse даёт warning, но не падает, description становится строкой "Doc block 'revneue' not found". Через полгода — 20 broken refs, никто не знает.
Пример CI script scripts/check_doc_blocks.py:
#!/usr/bin/env python3
import json, re, sys, glob, pathlib
# 1) Собираем все объявленные doc blocks из .md в models/
declared = set()
for md in glob.glob('models/**/*.md', recursive=True):
text = pathlib.Path(md).read_text()
declared.update(re.findall(r'{%\s*docs\s+(\w+)\s*%}', text))
# 2) Собираем все ссылки из YAML / .md
referenced = set()
for path in glob.glob('models/**/*.yml', recursive=True) + glob.glob('models/**/*.md', recursive=True):
text = pathlib.Path(path).read_text()
referenced.update(re.findall(r'''doc\(\s*['"]([^'"]+)['"]\s*\)''', text))
# 3) Проверки
broken = referenced - declared # ссылки на несуществующие
orphans = declared - referenced # объявлены, но никто не ссылается
errors = []
if broken:
errors.append(f"Broken doc refs: {sorted(broken)}")
if orphans:
errors.append(f"Orphaned doc blocks: {sorted(orphans)}")
if errors:
print('\n'.join(errors), file=sys.stderr)
sys.exit(1)
print(f"OK: {len(declared)} doc blocks, {len(referenced)} references, all resolved")
Запуск в CI:
# .github/workflows/dbt-ci.yml
- name: Check doc blocks
run: python scripts/check_doc_blocks.py
Падает при typo, при orphaned blocks (объявили, не используете — кладбище в _glossary.md), при broken refs. Один из самых high-leverage gates в documentation pipeline.
Когда выносить термин в doc block
Эвристика: если описание используется в 3+ местах, или это бизнес-критичный термин — выноси в doc block.
Простой тест: если ты пишешь одно и то же описание в 3-й раз — оторви в doc block. Если описание зависит от контекста модели — оставь inline.
Doc blocks и model descriptions
Можно ссылаться на doc block и в model description:
{% docs customer_metrics_overview %}
**customer_metrics** — KPI для customer success.
**Grain**: один клиент.
**Метрики**:
- `revenue` — {{ doc("revenue") }}
- `ltv` — {{ doc("ltv") }}
- `is_churned` — {{ doc("churn") }}
**Обновление**: ежедневно 06:00 UTC.
**Owner**: [email protected].
{% enddocs %}
YAML:
- name: customer_metrics
description: '{{ doc("customer_metrics_overview") }}'
Это позволяет писать большое описание модели в .md файле (где markdown render комфортнее, чем в YAML), а YAML оставлять компактным.
Doc blocks могут ссылаться на другие doc blocks через nested {{ doc(...) }}. Это удобно для composition: один блок «customer_metrics_overview» включает другие блоки «revenue», «ltv», «churn».
doc(‘name’, ‘project_name’) для cross-project
В dbt Mesh (multi-project setup) или при импорте package с glossary можно ссылаться на doc block из другого dbt-проекта вторым аргументом:
description: '{{ doc("revenue", "shared_finance_project") }}'
Production-пример: организация с 3-4 dbt-проектами (analytics, finance, ml) — все ссылаются на единый shared_glossary package:
# packages.yml каждого проекта
packages:
- git: "https://github.com/myorg/dbt-shared-glossary.git"
revision: v1.4.0
# dbt_packages/shared_glossary/models/_glossary.md
{% docs revenue %}
**Revenue** — canonical definition, governed by finance-team.
...
{% enddocs %}
# analytics проект — ссылается на canonical
- name: revenue
description: '{{ doc("revenue", "shared_glossary") }}'
Преимущества:
- Один источник правды для всей организации.
- Версионирование через git tag —
v1.4.0->v1.5.0с подтверждением через PR. - При изменении definition revenue — один PR в
shared_glossary, downstream проекты подхватывают черезdbt deps.
Trade-off: добавляется dependency на shared package. Pin версии в packages.yml обязателен — иначе breaking change в glossary ломает все downstream.
Подробнее dbt Mesh топология — в dbt-iii.
Антипаттерны doc blocks
-
Doc block для уникальной колонки:
{% docs customer_metrics_unique_field %}— используется только в одной модели. Оверкилл. Inline проще. -
Очень длинный doc block: 2000 слов. Никто не читает. Лимит: 500 слов или таблицы. Если больше — оторви в отдельный
.mdфайл и ссылайся через external link. -
doc block без проверки на presence: ссылаешься на
{{ doc('revneue') }}(typo) — dbt парсит, генерит warning, но не падает. Description становится строкой"Doc block 'revneue' not found". CI должен ловить. -
doc block с markdown ошибками:
# Headingв_docs.mdрендерится в dbt docs UI неожиданно — стиль не совпадает. Используй**bold**для emphasis, и## Headingдля разделов в docs UI. -
Doc block с SQL кодом: doc block с SQL внутри — это полезно для context. Но SQL должен быть в markdown code blocks, иначе ломает rendering. Также — следить за
{{ }}внутри SQL: Jinja попытается резолвить.Если в doc block нужен пример SQL с
{{ ref(...) }}, escape через raw block:{% raw %} SELECT * FROM {{ ref('stg_orders') }} {% endraw %} -
Дубли: два разных doc blocks с похожим именем
revenue_v1,revenue_v2без deprecation. Решение: один canonical блок + DEPRECATED note в старом.
{% raw %} escaping для метасимволов в descriptions
В doc block может понадобиться показать код с Jinja-синтаксисом — пример {{ ref(...) }}, {{ source(...) }}, {% if %}. Без escape Jinja попытается резолвить эти теги при dbt docs generate и упадёт (или выдаст 'ref' is undefined).
Решение — {% raw %}...{% endraw %} блок:
{% docs incremental_pattern %}
**Incremental model pattern**:
{% raw %}
{{ config(materialized='incremental', unique_key='event_id') }}
SELECT * FROM {{ source('events', 'raw') }}
{% if is_incremental() %}
WHERE created_at > (SELECT MAX(created_at) FROM {{ this }})
{% endif %}
{% endraw %}
Также `{% raw %}{{ var('lookback_days') }}{% endraw %}` берётся из dbt_project.yml.
{% enddocs %}
Без {% raw %} пример с {{ source('events', 'raw') }} попытается резолвить source — упадёт.
Где ещё нужен {% raw %}:
- doc blocks с примерами SQL для macros (типа
{{ safe_divide(...) }}). - doc blocks с
{% if target.name == 'prod' %}snippets. - любой
{{ }}или{% %}в тексте, который должен показываться как литерал, а не резолвиться.
Без понимания этого паттерна 30% production doc blocks с код-примерами просто сломаны.
Попробуй сам
В твоём dbt-проекте:
-
Identify термины: на 3-5 mart-моделях посмотри column descriptions. Какие колонки повторяются (customer_id, revenue, …)?
-
Создать
models/_glossary.md:{% docs customer_id %} ... {% enddocs %} {% docs revenue %} ... {% enddocs %} -
Обновить YAML: заменить inline descriptions на
{{ doc("name") }}для 5-10 повторяющихся колонок. -
Прогон:
dbt parse dbt docs generate dbt docs serve -
Проверка в UI: открыть в браузере, найти колонку
customer_id— должна показывать содержимое doc block. -
Тест на typo: сделать
{{ doc("custmer_id") }}(typo) —dbt parseдолжен warning. CI должен fail.
Ключевые выводы
- Doc block — переиспользуемый блок документации в
.mdфайле, объявляется через{% docs name %}...{% enddocs %}, ссылается из YAML через{{ doc('name') }}. - Лежат в
models/_glossary.mdилиmodels/<dir>/_<context>__docs.md. По умолчанию dbt видит все.mdвmodels/. - YAML quoting: внешние кавычки отличаются от внутренних. Convention:
'{{ doc("name") }}'. Block scalar (|) для multi-line. - Когда выносить: используется в 3+ местах ИЛИ это бизнес-критичный термин (revenue, churn, customer_id). НЕ выносить для уникальных или контекст-зависимых колонок.
- Можно ссылаться на doc block в model description через тот же синтаксис. Удобно для длинных описаний модели — пишутся в
.md, в YAML только ссылка. - Doc blocks могут ссылаться друг на друга (nested doc(‘name’)). Это позволяет composition: «customer_metrics_overview» включает «revenue», «ltv».
- Антипаттерны: doc block для уникальной колонки, без проверки typo (broken ref), markdown errors, SQL с unescaped Jinja (нужен
{% raw %}). - CI gate: grep
_glossary.mdна orphaned blocks, grep YAML на broken refs. Без CI doc blocks дрейфуют.