Learning Platform
Глоссарий Troubleshooting
Урок 06.01 · 25 мин
Средний
MacrosDesign patternsProductionJinjaCode organization

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), здесь — про общий дизайн.

NOTE

Базовый синтаксис {% 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

Перед паттернами — короткий список типичных ошибок.

Macro anti-patterns

Каждый 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 по диффу одной директории.

NOTE

Один 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.

Минимально включить:

  1. Назначение в одну строку.
  2. Args с типами и описанием.
  3. Returns — что вернётся (SQL expression / list / dict).
  4. Example — реальный usage.
  5. 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:

  1. Создайте macro format_currency(amount, currency_code) — возвращает форматированную строку вида ‘$1,234.56’. Включите docstring с Args, Returns, Example.
  2. Напишите singular test на этот macro: dbt test проверяет что compile output корректен для нескольких inputs.
  3. Расширьте на dispatch: один реальный pattern в Snowflake / Postgres / DuckDB.
  4. Создайте deprecation pattern: добавьте format_currency_v2 с улучшенным NULL handling, старый emit warning.
  5. Добавьте macro в _macros.yml для документации.

Это упражнение показывает полный production-цикл macro: write, document, test, deprecate, replace.


Ключевые выводы

  1. Macros — это Jinja-функции для генерации SQL. В production-проекте их 50+; нужна дисциплина.
  2. Naming: глагольно-объектный namespace, snake_case. Префикс myproject_ для wrappers над packages.
  3. Структура: поддиректории в macros/ по функциональности — для людей, dbt видит все макросы плоско.
  4. Документация: docstring с Args / Returns / Example в каждом macro. Опционально — _macros.yml + doc blocks.
  5. Тесты: singular tests на compile output, dbt unit tests через test-models, analyses/ для sanity check.
  6. Reusability: small focused macros + composition. Избегайте mega-macros.
  7. Validation: exceptions.raise_compiler_error() на invalid args. Лучше упасть с понятным сообщением, чем silent corruption.
  8. Versioning: deprecated wrappers с exceptions.warn() для миграции.
  9. Macro vs model: macro для SQL fragments, model для standalone datasets.
Проверка знанийKnowledge check
Junior хочет один универсальный macro 'transform_data(table_name, dedup_cols, rename_map, add_audit_flag, filter_expression, ...)'. Что предложить?
ОтветAnswer
Это типичный god-macro антипаттерн. Предложите декомпозицию.\n\nПроблемы god-macro:\n\n1. **Hard to test**: 10 параметров -> 2^10 combinations поведений. Невозможно покрыть тестами.\n2. **Hard to read**: usage `{{ transform_data(table, ['id'], {'old': 'new'}, true, "status != 'cancelled'") }}` — что делает каждый параметр? Документация нужна для каждого call.\n3. **Hard to reuse partially**. Нужен только dedup без rename — приходится передавать пустые map'ы. Запутывает.\n4. **Hard to extend**: новая feature -> новый параметр -> новая дефолтная семантика -> breaking change для существующих usages.\n\nПредлагаемая декомпозиция:\n\n```sql\n-- macros/dedupe.sql\n{% macro dedupe(rel, partition_by, order_by) %}...{% endmacro %}\n\n-- macros/rename_columns.sql\n{% macro rename_columns(rel, mapping) %}...{% endmacro %}\n\n-- macros/add_audit_columns.sql\n{% macro add_audit_columns() %}...{% endmacro %}\n\n-- macros/apply_filter.sql\n{% macro apply_filter(rel, expr) %}...{% endmacro %}\n```\n\nИспользование — composition в модели:\n\n```sql\nWITH deduped AS (\n {{ dedupe(source('app', 'customers'), ['customer_id'], 'updated_at DESC') }}\n),\nrenamed AS (\n SELECT *, -- ... rename inline since dbt models can do that natively\n old_name AS new_name\n FROM deduped\n),\nfiltered AS (\n SELECT * FROM renamed WHERE status != 'cancelled'\n)\nSELECT *,\n {{ add_audit_columns() }}\nFROM filtered\n```\n\nКаждый macro делает одно. Composition в SQL модели — это **читабельность** SQL вместо invisible Jinja-magic. Все параметры на видном месте, легко тестировать каждый шаг отдельно.\n\nЕсли композиция повторяется в 5+ моделях — выделить в **модель-обёртку** (intermediate model), не god-macro:\n\n```sql\n-- models/intermediate/int_customers_clean.sql\nWITH deduped AS ({{ dedupe(...) }}),\nfiltered AS (...)\nSELECT * FROM filtered\n```\n\nТеперь модели делают `SELECT * FROM {{ ref('int_customers_clean') }}` — DRY без god-macro.\n\nGeneral principle: **macros — фрагменты SQL, models — datasets**. Не пытайтесь macros делать full transformations.
Проверка знанийKnowledge check
Macro используется в 30 моделях. Команда хочет улучшить его NULL handling. Какие 3 подхода и компромисс между ними?
ОтветAnswer
Три подхода с trade-offs:\n\n**Подход 1: Изменить in-place (just edit the macro).**\n\nПросто пишем новый код в существующем macro. Все 30 моделей используют новую логику с следующего `dbt run`.\n\nPros:\n- Просто. Один git commit.\n- Все модели сразу на новой логике.\n\nCons:\n- Если новая логика ломает downstream — все 30 моделей сразу red в CI.\n- Нельзя постепенно мигрировать.\n- Нет shadow comparison старая/новая.\n\nКогда выбирать: маленькая команда, есть полное CI с tests на downstream, изменение тривиальное.\n\n**Подход 2: Feature flag через var.**\n\n```sql\n{% macro safe_divide(a, b, fallback=0) %}\n{% if var('use_new_safe_divide', false) %}\n -- new implementation\n{% else %}\n -- old implementation\n{% endif %}\n{% endmacro %}\n```\n\nЗапуск с `--vars 'use_new_safe_divide: true'` — на dev / staging. Прод остаётся на старой. Постепенно тестируем, потом переключаем default.\n\nPros:\n- Постепенная rollout. Сначала dev, потом staging, потом prod.\n- Easy rollback: переключить var обратно.\n\nCons:\n- Сложнее код macros (двойная логика на время).\n- Тестовое покрытие удваивается (обе ветки).\n- Может задержаться в feature flag mode долго ('временно навсегда').\n\nКогда выбирать: critical macros в production, нужен gradual rollout, есть multi-env CI.\n\n**Подход 3: Versioned macros (deprecated wrapper).**\n\n```sql\n-- macros/safe_divide.sql — старая, deprecated\n{% macro safe_divide(a, b, fallback=0) %}\n {% do log('safe_divide is deprecated, use safe_divide_v2', info=true) %}\n -- old implementation\n{% endmacro %}\n\n-- macros/safe_divide_v2.sql — новая\n{% macro safe_divide_v2(a, b, fallback=0) %}\n -- new implementation\n{% endmacro %}\n```\n\nМиграция модели за моделью: меняем `safe_divide` на `safe_divide_v2` в каждом call-site. Когда все мигрированы — удалить старую.\n\nPros:\n- Полный контроль когда какая модель переходит.\n- Можно держать обе версии в проекте долго без vars.\n- Явная audit trail в diff'ах PR.\n\nCons:\n- 30 PR-ов нужно сделать (или один большой).\n- Risk leftovers — забыть мигрировать одну модель.\n- Naming pollution в проекте (safe_divide_v2, _v3, _v4).\n\nКогда выбирать: very critical macros, академический approach, медленная команда без real-time deploy.\n\n**Рекомендация для middle проекта**: combination.\n\n1. Малые изменения, не breaking — Подход 1 (in-place edit).\n2. Significant logical change, всё ещё backwards compatible — Подход 2 (feature flag).\n3. Breaking change в API — Подход 3 (versioned macros).\n\nИ всегда: tests на downstream до merge. Без CI с tests любой подход чреват regressions.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Junior предлагает один универсальный macro transform_data(table, dedup_cols, rename_map, add_audit, filter_expr, ...). Какой главный аргумент против?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5