Production-grade macros: design patterns
В курсе junior вы писали один-два macros для подстановки SQL. В production проект на 100+ моделей быстро накапливает 50-100 кастомных макросов: hooks, generic tests, utility-функции, обёртки над dispatch. Без дисциплины это превращается в jungle, где никто не понимает что какой macro делает.
Этот урок — про инженерные практики для macros: naming, namespacing, документация, тесты, версионирование. Дальше в модуле — про конкретные техники (adapter.dispatch, run_query), здесь — про общий дизайн.
Базовый синтаксис {% macro %} и пример cents_to_dollars разбирались в dbt-i/10/01. Если помнишь — переходи прямо к “Anti-patterns: что НЕ делать”. Здесь — design patterns для production-grade макросов.
Middle-уровневый baseline: safe_divide
Чтобы не пересказывать cents_to_dollars из dbt-i, для всех следующих разделов будем брать пример посерьёзнее — safe_divide, который должен корректно обрабатывать NULL и деление на ноль:
-- macros/safe_divide.sql
{% macro safe_divide(numerator, denominator, fallback=0) %}
CASE
WHEN ({{ denominator }}) IS NULL OR ({{ denominator }}) = 0 THEN {{ fallback }}
WHEN ({{ numerator }}) IS NULL THEN {{ fallback }}
ELSE ({{ numerator }}) / NULLIF(({{ denominator }}), 0)
END
{% endmacro %}
Использование:
SELECT
customer_id,
{{ safe_divide('total_revenue', 'order_count') }} AS avg_order_value
FROM {{ ref('customer_orders') }}
Здесь уже минимум три production-вопроса, которые в cents_to_dollars не возникали: что считать fallback, как валидировать аргументы, как тестировать ветку «деление на ноль». Эти вопросы и есть наша design-канва на всё дальше.
Anti-patterns: что НЕ делать
dbt-i: базовый синтаксис macros dbt-iii: parse vs execute phase — когда работают macrosПеред паттернами — короткий список типичных ошибок.
Каждый anti-pattern обходится паттерном из следующих разделов.
Naming conventions
В production команде договариваются о naming. Один из работающих подходов — глагольно-объектный namespace:
{verb}_{object}_{detail}
Примеры:
| Имя | Что делает |
|---|---|
format_phone_number(col) | Форматирует phone в стандарт E.164 |
cast_to_money(col, precision) | Cast value to money type |
audit_inserts(table, batch_id) | Записывает audit row про INSERT |
dispatch_dedupe(source_table) | Dispatch на adapter-specific dedupe |
Что избегать:
helper_*,util_*,do_*,fix_*— слишком общее, не говорит что делает.customers_macro,orders_macro— суффикс_macroизбыточен.- camelCase в Jinja — традиционно snake_case в Python / SQL ecosystems.
Для обёрток над package macros — добавьте префикс проекта:
-- macros/myproject_get_columns_in_relation.sql
-- Wrapper around dbt_utils.get_columns_in_relation
{% macro myproject_get_columns_in_relation(relation, schema=none) %}
{% set cols = dbt_utils.get_columns_in_relation(relation) %}
{# наш custom фильтр - убираем audit columns #}
{% set cols = cols | rejectattr('name', 'in', ['_loaded_at', '_source_id']) | list %}
{{ return(cols) }}
{% endmacro %}
Префикс myproject_ явно говорит «это наш wrapper», не package macro. Помогает при поиске и при upgrade пакетов (видно где наша логика, где их).
Структура файлов
В маленьком проекте можно держать все macros в macros/:
macros/
cents_to_dollars.sql
format_phone.sql
audit_inserts.sql
...
В большом проекте — поддиректории по функциональным группам:
macros/
custom_tests/ # generic tests (макросы test_*)
test_no_nulls_after.sql
test_valid_email.sql
formatting/ # форматирование значений
cents_to_dollars.sql
format_phone.sql
hooks/ # pre-hooks / post-hooks / on-run-end
audit_inserts.sql
grant_select_to.sql
dispatch/ # adapter-dispatch wrappers
dispatch_dedupe.sql
default__dispatch_dedupe.sql
duckdb__dispatch_dedupe.sql
utilities/ # общие хелперы
get_unique_keys.sql
parse_json_safe.sql
dbt не учитывает структуру директорий для namespace — macros доступны глобально по имени. Структура нужна для людей: легче найти, понять группу, code review по диффу одной директории.
Один macro = один файл. Это convention dbt. Имя файла = имя macro. Это упрощает grep, find references, IDE-навигацию.
Documentation
Каждый production macro должен иметь docstring в начале:
{% macro safe_divide(numerator, denominator, fallback=0) %}
{#
Division с защитой от NULL и деления на ноль.
Args:
numerator (str): SQL expression-числитель (имя колонки или выражение).
denominator (str): SQL expression-знаменатель.
fallback (numeric | str): значение/expression при NULL или нуле, default 0.
Returns:
SQL CASE expression: numerator / denominator с fallback на NULL / 0.
Example:
SELECT {{ safe_divide('total_revenue', 'order_count') }} AS avg_order_value
FROM customer_orders
Notes:
- NULLIF в знаменателе — защита от race-conditions между WHEN и ELSE.
- Работает на DuckDB / Postgres / Snowflake / BigQuery.
- Для BigQuery типизация результата может потребовать SAFE_CAST — см. safe_divide_bq.
#}
CASE
WHEN ({{ denominator }}) IS NULL OR ({{ denominator }}) = 0 THEN {{ fallback }}
WHEN ({{ numerator }}) IS NULL THEN {{ fallback }}
ELSE ({{ numerator }}) / NULLIF(({{ denominator }}), 0)
END
{% endmacro %}
Docstring — это Jinja-комментарий {# ... #}. dbt-docs автоматически парсит и показывает в Explorer / docs site.
Минимально включить:
- Назначение в одну строку.
- Args с типами и описанием.
- Returns — что вернётся (SQL expression / list / dict).
- Example — реальный usage.
- Notes — edge cases, warehouse-compatibility, deprecation статус.
Лучшая практика: писать docstring до реализации. Это упрощает API design — заставляет думать «как этим будут пользоваться?» перед «как это реализовать?».
Тестирование macros
Macros должны тестироваться отдельно от моделей. Подходов несколько:
1. Singular tests на compile output
-- tests/test_safe_divide_handles_zero.sql
WITH cases AS (
SELECT 10 AS num, 2 AS den, 5.0 AS expected
UNION ALL SELECT 10, 0, 0 -- деление на ноль -> fallback
UNION ALL SELECT NULL, 2, 0 -- NULL числитель -> fallback
UNION ALL SELECT 10, NULL, 0 -- NULL знаменатель -> fallback
)
SELECT *
FROM cases
WHERE {{ safe_divide('num', 'den') }} != expected
OR ({{ safe_divide('num', 'den') }} IS NULL AND expected IS NOT NULL)
Если macro работает корректно — 0 строк, тест проходит. Любой mismatch — строка с проблемным кейсом.
2. Compile-output assertion
Бывает нужно проверить именно сгенерированный SQL (для критичных мест, где порядок CASE WHEN влияет):
-- tests/test_safe_divide_compiles.sql
{% if execute %}
{% set rendered = safe_divide('100', '5') %}
{% if 'NULLIF' not in rendered %}
{{ exceptions.raise_compiler_error('safe_divide must use NULLIF for race safety, got: ' ~ rendered) }}
{% endif %}
{% endif %}
SELECT 1 WHERE 1=0 -- empty if assertion passes
Регрессия дизайна (например, кто-то убрал NULLIF) — мгновенный fail в CI.
3. dbt unit tests (1.8+)
Полноценные unit tests доступны для моделей. Для macros — через тест-model, в котором используется macro:
# models/_unit_tests.yml
unit_tests:
- name: test_safe_divide_basic
model: test_macro_outputs
given:
- input: ref('test_fixtures')
rows:
- {numerator: 10, denominator: 2}
- {numerator: 10, denominator: 0}
- {numerator: null, denominator: 5}
expect:
rows:
- {numerator: 10, denominator: 2, result: 5.0}
- {numerator: 10, denominator: 0, result: 0}
- {numerator: null, denominator: 5, result: 0}
-- models/test_macro_outputs.sql
{{ config(materialized='ephemeral', tags=['test']) }}
SELECT
numerator,
denominator,
{{ safe_divide('numerator', 'denominator') }} AS result
FROM {{ ref('test_fixtures') }}
Это полная unit-инфраструктура. Подробнее в модуле 08-unit-tests.
4. Контрольная модель в analyses/
analyses/ — это директория для ad-hoc SQL, который не материализуется. Туда можно положить sanity-check для macro:
-- analyses/check_safe_divide.sql
SELECT
{{ safe_divide('10', '2') }} AS normal, -- expected 5
{{ safe_divide('10', '0') }} AS divide_zero, -- expected 0
{{ safe_divide('NULL', '5') }} AS null_num -- expected 0
dbt compile --select check_safe_divide покажет compiled SQL. Не запускается автоматически, но полезно для дебага.
Reusability vs DRY
Соблазн: написать один универсальный macro на все случаи. Результат — gigantic macro с 10 опциональными параметрами, который никто не понимает.
Альтернатива: small focused macros + composition.
Пример: нужно дедуплицировать таблицу + переименовать колонки + добавить audit columns. Можно одной мега-macros, можно тремя:
-- macros/dedupe.sql — только dedup, ничего лишнего
{% macro dedupe(source_relation, partition_by, order_by) %}
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY {{ partition_by | join(', ') }}
ORDER BY {{ order_by }}
) AS rn
FROM {{ source_relation }}
)
WHERE rn = 1
{% endmacro %}
-- macros/add_audit_columns.sql
{% macro add_audit_columns() %}
'{{ invocation_id }}' AS _dbt_invocation_id,
'{{ run_started_at }}' AS _dbt_inserted_at
{% endmacro %}
-- В модели — композиция
WITH deduped AS (
{{ dedupe(source('app', 'customers'), ['customer_id'], 'updated_at DESC') }}
)
SELECT
*,
{{ add_audit_columns() }}
FROM deduped
Каждая macro делает одно. Композицию пишет потребитель в SQL модели. Тестировать проще, переиспользовать гибче.
Аргументы macros: typed defaults
Jinja не имеет статической типизации, но можно (и нужно) делать validation в начале macro. Расширим наш safe_divide явной защитой от неправильных вызовов:
{% macro safe_divide(numerator, denominator, fallback=0) %}
{# Validation — лучше упасть с понятным сообщением, чем silent NULL #}
{% if numerator is none or denominator is none %}
{{ exceptions.raise_compiler_error(
'safe_divide: numerator и denominator обязательны (получено: numerator=' ~ numerator ~ ', denominator=' ~ denominator ~ ')'
) }}
{% endif %}
{# Implementation #}
CASE
WHEN ({{ denominator }}) IS NULL OR ({{ denominator }}) = 0 THEN {{ fallback }}
WHEN ({{ numerator }}) IS NULL THEN {{ fallback }}
ELSE ({{ numerator }}) / NULLIF(({{ denominator }}), 0)
END
{% endmacro %}
exceptions.raise_compiler_error() останавливает dbt компиляцию с явным сообщением. Это лучше чем silent NULL / wrong result в downstream.
Дополнительные паттерны validation:
{# Список допустимых значений #}
{% set allowed_strategies = ['merge', 'delete+insert', 'append'] %}
{% if strategy not in allowed_strategies %}
{{ exceptions.raise_compiler_error('strategy must be one of ' ~ allowed_strategies | join(', ') ~ ', got: ' ~ strategy) }}
{% endif %}
{# Тип проверка #}
{% if columns is not iterable or columns is string %}
{{ exceptions.raise_compiler_error('columns must be a list, got: ' ~ columns | string) }}
{% endif %}
Validation в начале macro — это контракт для пользователя. Лучше упасть при compile с понятным сообщением, чем silent corrupted data.
Версионирование
Macro используется в 30 моделях. Хочется изменить — но менять везде сразу опасно.
Паттерн versioned macros:
-- macros/safe_divide.sql — старая версия, deprecated
{% macro safe_divide(numerator, denominator, fallback=0) %}
{% do exceptions.warn('safe_divide is deprecated, use safe_divide_v2 (с typed fallback и SAFE_CAST)') %}
{{ return(safe_divide_v2(numerator, denominator, fallback)) }}
{% endmacro %}
-- macros/safe_divide_v2.sql — новая, с типизированным fallback
{% macro safe_divide_v2(numerator, denominator, fallback=0, result_type='NUMERIC') %}
CASE
WHEN ({{ denominator }}) IS NULL OR ({{ denominator }}) = 0 THEN CAST({{ fallback }} AS {{ result_type }})
WHEN ({{ numerator }}) IS NULL THEN CAST({{ fallback }} AS {{ result_type }})
ELSE CAST(({{ numerator }}) AS {{ result_type }}) / CAST(NULLIF(({{ denominator }}), 0) AS {{ result_type }})
END
{% endmacro %}
Старая версия эмитит warning, делегирует к новой. Можно постепенно мигрировать модели на v2. Когда все на v2 — удалить старую.
Альтернатива — git-флоу с feature branch + atomic миграция (если 30 моделей не критично).
Когда macro vs ref-модель
Junior часто пытается всё засунуть в macro. Не всегда правильно.
| Используйте macro | Используйте ref-модель |
|---|---|
| SQL fragment, который повторяется в WHERE / SELECT / JOIN clauses | Готовый dataset (таблица / view), которая будет в DAG |
| Логика зависит от runtime-arguments (column names, conditional logic) | Логика deterministic, нет зависимости от call-site |
Нужен access к target.name, var(), adapter dispatch | Нужно тестировать как полную модель |
| Используется в moving parts (hooks, snapshot config) | Финальный artifact для downstream / BI |
Например:
safe_divide(num, den)— macro. Это фрагмент SELECT, нужен с разными колонками и контекстами.dim_customers— ref-модель. Это готовая таблица.audit_log— может быть и macro (если генерится в каждой модели), и ref-модель (если централизованный log).
Не делайте «macro вместо модели» там, где можно нормально модель.
Documentation в dbt docs
Macros появляются в dbt docs generate если в проекте включена их генерация:
# dbt_project.yml
docs-paths: ["docs"]
Иногда хочется задокументировать macros централизованно через doc blocks:
-- docs/macros.md
{% docs macro_safe_divide %}
Защищённое деление с обработкой NULL и деления на ноль.
Работает на DuckDB, Postgres, Snowflake, BigQuery.
{% enddocs %}
# macros/_macros.yml
macros:
- name: safe_divide
description: "{{ doc('macro_safe_divide') }}"
arguments:
- name: numerator
type: str
description: "SQL expression-числитель"
- name: denominator
type: str
description: "SQL expression-знаменатель"
- name: fallback
type: numeric
description: "Значение при NULL или нуле, default 0"
Это даёт searchable docs в Explorer / DocsSite. На небольшом проекте overkill, на большом — спасает.
Попробуй сам
В labs:
- Создайте macro
format_currency(amount, currency_code)— возвращает форматированную строку вида ‘$1,234.56’. Включите docstring с Args, Returns, Example. - Напишите singular test на этот macro: dbt test проверяет что compile output корректен для нескольких inputs.
- Расширьте на dispatch: один реальный pattern в Snowflake / Postgres / DuckDB.
- Создайте deprecation pattern: добавьте
format_currency_v2с улучшенным NULL handling, старый emit warning. - Добавьте macro в
_macros.ymlдля документации.
Это упражнение показывает полный production-цикл macro: write, document, test, deprecate, replace.
Ключевые выводы
- Macros — это Jinja-функции для генерации SQL. В production-проекте их 50+; нужна дисциплина.
- Naming: глагольно-объектный namespace, snake_case. Префикс
myproject_для wrappers над packages. - Структура: поддиректории в
macros/по функциональности — для людей, dbt видит все макросы плоско. - Документация: docstring с Args / Returns / Example в каждом macro. Опционально —
_macros.yml+ doc blocks. - Тесты: singular tests на compile output, dbt unit tests через test-models,
analyses/для sanity check. - Reusability: small focused macros + composition. Избегайте mega-macros.
- Validation:
exceptions.raise_compiler_error()на invalid args. Лучше упасть с понятным сообщением, чем silent corruption. - Versioning: deprecated wrappers с
exceptions.warn()для миграции. - Macro vs model: macro для SQL fragments, model для standalone datasets.