Learning Platform
Глоссарий Troubleshooting
Урок 12.01 · 18 мин
Начальный
dbtJinjamacros

Определение macros: синтаксис, аргументы, return

В прошлом модуле мы изучили Jinja — синтаксис, control flow, parse vs execute. Теперь применим эту базу к самому ценному инструменту dbt: macros. Macros — это переиспользуемые блоки Jinja, которые позволяют вынести повторяющуюся логику из моделей в отдельные именованные функции. Без macros большой dbt-проект превращается в копи-пастный кошмар; с macros — в чистый DRY-код.

Что такое macro

Macro — это именованный кусок Jinja, который можно вызывать из других шаблонов (моделей, тестов, других macros). Аналог функции в Python. Объявляется через {% macro %} ... {% endmacro %}.

-- macros/cents_to_dollars.sql

{% macro cents_to_dollars(column_name) %}
    ({{ column_name }} / 100.0)::numeric(16, 2)
{% endmacro %}

И вызывается из модели:

-- models/marts/marts__orders.sql

select
    order_id,
    {{ cents_to_dollars('total_amount_cents') }} as total_amount,
    {{ cents_to_dollars('tax_amount_cents') }} as tax_amount
from {{ ref('stg_jaffle__orders') }}

После компиляции:

select
    order_id,
    (total_amount_cents / 100.0)::numeric(16, 2) as total_amount,
    (tax_amount_cents / 100.0)::numeric(16, 2) as tax_amount
from "jaffle_shop"."main"."stg_jaffle__orders"

Та же логика, развёрнутая в SQL дважды. Если завтра вы поменяете формулу (например, на trillions / 10^15), достаточно изменить macro — все вызовы обновятся.

Анатомия macro

{% macro <name>(<arg1>, <arg2>=<default>, ...) %}
    <тело — Jinja и SQL>
{% endmacro %}

Элементы:

  • {% macro %} — открывающий тег statement.
  • name — имя macro. По нему он будет вызываться из других мест. Snake_case.
  • (arg1, arg2=default, ...) — список параметров. Можно с defaults.
  • тело — любой Jinja + SQL. Что выведется в output — то и подставится в место вызова.
  • {% endmacro %} — закрывающий тег.

Имена аргументов локальны: внутри macro arg1 ссылается на переданное значение, снаружи это имя не существует.

Где живут macros

Дефолтная локация — каталог macros/ в корне проекта:

my_project/
├── models/
├── macros/
│   ├── cents_to_dollars.sql
│   ├── safe_divide.sql
│   └── date_helpers/
│       ├── get_fiscal_quarter.sql
│       └── add_business_days.sql
├── tests/
└── dbt_project.yml

dbt сканирует каталог macros/ рекурсивно. Имя файла не обязано совпадать с именем macro — внутри можно положить несколько macros. Но конвенция — один macro в одном файле с совпадающим именем. Так проще искать.

-- macros/safe_divide.sql

{% macro safe_divide(numerator, denominator) %}
    case 
        when {{ denominator }} = 0 then null
        else {{ numerator }} / {{ denominator }}
    end
{% endmacro %}

-- В одном файле можно положить и helper, который используется только safe_divide:
{% macro _safe_divide_validate(name, value) %}
    {# private helper #}
    -- проверка...
{% endmacro %}
NOTE

Macros, начинающиеся с _ — это конвенция «приватный». dbt их видит так же, но для команды это сигнал «не вызывай напрямую, это helper».

Аргументы и defaults

Передавать аргументы можно позиционно или по имени.

-- Объявление
{% macro pad_string(input, length, pad_char=' ') %}
    lpad({{ input }}, {{ length }}, '{{ pad_char }}')
{% endmacro %}

Использование:

-- Все позиционно
{{ pad_string('country_code', 3, '0') }}

-- С default (pad_char не передаём)
{{ pad_string('country_code', 3) }}

-- Именованные параметры
{{ pad_string(input='country_code', length=3, pad_char='0') }}

-- Смешанные
{{ pad_string('country_code', length=3) }}

В скомпилированном SQL:

lpad(country_code, 3, '0')
lpad(country_code, 3, ' ')
lpad(country_code, 3, '0')
lpad(country_code, 3, ' ')

Defaults применяются, если аргумент не передан. Если в signature нет default — параметр обязателен.

WARNING

В Jinja2 нет строгого type checking. Если передать число туда, где ожидалась строка, ошибка может быть тихой: SQL соберётся, но warehouse выкинет syntax error. Документируйте тип в комментариях.

Что выводится в output

Macro генерирует всё, что внутри его тела, кроме statements. То есть:

  • Expressions {{ }} — попадают в output.
  • Текст между тегами — попадает в output.
  • Statements {% %} — не попадают, но их эффекты применяются.
  • Comments {# #} — удаляются.

Пример:

{% macro example(x) %}
    {# Это комментарий, не попадёт #}
    {% set y = x * 2 %}  
    SELECT {{ y }} as doubled
{% endmacro %}

При вызове {{ example(5) }}:

    
      
    SELECT 10 as doubled

Whitespace сохраняется. Хотите подрезать — используйте -:

{% macro example(x) -%}
{%- set y = x * 2 %}
SELECT {{ y }} as doubled
{%- endmacro %}

Возврат значений: implicit и explicit

В dbt-macros есть две стратегии возврата:

Implicit return: текст в output

Самый частый случай. Macro генерирует SQL-фрагмент:

{% macro cents_to_dollars(col) %}
    ({{ col }} / 100.0)::numeric(16, 2)
{% endmacro %}

Вызов {{ cents_to_dollars('x') }} подставляет в место вызова результат рендеринга тела. Возвращается «строка».

Explicit return через {% do return(...) %}

Когда macro должен вернуть не строку, а Python-объект (список, словарь, число), используется return():

{% macro get_my_columns() %}
    {% set columns = ['order_id', 'order_date', 'total_amount'] %}
    {% do return(columns) %}
{% endmacro %}

Использование:

{% set cols = get_my_columns() %}

select
    {% for col in cols %}
    {{ col }}{% if not loop.last %},{% endif %}
    {% endfor %}
from {{ ref('orders') }}

return() останавливает выполнение macro и возвращает Python-значение. Не генерирует output. Поэтому при таком вызове нет {{ }} — мы хотим присвоить результат переменной, а не вставить в SQL.

Пример: helper для генерации списка

-- macros/get_date_range.sql

{% macro get_date_range(start_date, end_date) %}
    {% set dates = [] %}
    {% set start = modules.datetime.datetime.strptime(start_date, '%Y-%m-%d') %}
    {% set end = modules.datetime.datetime.strptime(end_date, '%Y-%m-%d') %}
    {% set delta = (end - start).days %}
    
    {% for i in range(delta + 1) %}
        {% set d = start + modules.datetime.timedelta(days=i) %}
        {% do dates.append(d.strftime('%Y-%m-%d')) %}
    {% endfor %}
    
    {% do return(dates) %}
{% endmacro %}

Использование:

{% set days = get_date_range('2026-01-01', '2026-01-05') %}

-- days теперь = ['2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05']

select
    {% for d in days %}
    '{{ d }}' as day_{{ loop.index }}{% if not loop.last %},{% endif %}
    {% endfor %}

modules.datetime — встроенный модуль Jinja2, доступный в dbt-окружении. Аналогично modules.itertools, modules.re.

Вызов macro из macro

Macros могут вызывать друг друга:

{% macro safe_divide(num, den) %}
    case when {{ den }} = 0 then null else {{ num }} / {{ den }} end
{% endmacro %}

{% macro percentage(part, whole) %}
    {{ safe_divide(part, whole) }} * 100
{% endmacro %}

Вызов {{ percentage('a', 'b') }} сначала развернёт percentage, потом safe_divide внутри:

case when b = 0 then null else a / b end * 100

Это стандартный приём композиции — мелкие helper’ы собираются в более крупные.

Adapter dispatch: один macro, разный SQL под разные warehouse

dbt позволяет писать macros, которые разное SQL под разные адаптеры (DuckDB, Postgres, Snowflake, BigQuery). Механика — dispatch. Подробно тема в курсе dbt II, на junior достаточно знать, что она существует.

Пример из самого dbt: dbt_utils.surrogate_key — генерирует одинаковую логику hash, но синтаксис md5() варьируется между адаптерами.

{% macro generate_surrogate_key(field_list) %}
    {{ return(adapter.dispatch('generate_surrogate_key', 'dbt_utils')(field_list)) }}
{% endmacro %}

{% macro default__generate_surrogate_key(field_list) %}
    md5(cast(concat({{ field_list | join(', ') }}) as varchar))
{% endmacro %}

{% macro bigquery__generate_surrogate_key(field_list) %}
    to_hex(md5(cast(concat({{ field_list | join(', ') }}) as string)))
{% endmacro %}

adapter.dispatch находит правильную реализацию для текущего target. DuckDB подхватит default__, BigQuery — bigquery__. Junior не пишет dispatch с нуля, но знать механизм полезно для чтения чужого кода.

Хорошие практики

1. Имя описывает результат, не процесс. cents_to_dollars — хорошо. convert_money — невнятно.

2. Один файл — один macro. Исключение — helper’ы с _ префиксом для одной публичной функции.

3. Docstring в комментарии. Что macro делает, какие args, что возвращает.

{% macro cents_to_dollars(column_name) %}
{# 
  Конвертирует цент-int колонку в доллары numeric(16,2).
  Args:
    column_name (str): имя SQL-колонки с центами
  Returns:
    SQL-фрагмент типа (col / 100.0)::numeric(16, 2)
#}
    ({{ column_name }} / 100.0)::numeric(16, 2)
{% endmacro %}

4. Не злоупотребляйте. Macro оправдан, когда логика повторяется в 3+ местах. Один раз — лучше inline SQL.

5. Тестируйте macros через models или unit tests. Macros сами по себе не тестируются — но модель, которая использует macro, тестируется обычным dbt test. Если macro кривой, тест модели поймает.

6. Простота лучше абстракции. Macro, который требует пяти параметров и трёх вложенных циклов — bad smell. Скорее всего, проще написать прямо в модели.

Распространённые ошибки

1. Macro вызывается без {{ }}.

{# WRONG #}
select {{ cents_to_dollars }} from t

{# RIGHT — даже если без аргументов, нужны скобки #}
select {{ macro_no_args() }} from t

2. Забыли {% do return %} в macro, возвращающем объект.

{# WRONG: тело генерирует output, return не вызвалось #}
{% macro get_cols() %}
    {% set cols = ['a', 'b'] %}
    {{ cols }}
{% endmacro %}

{% set x = get_cols() %}
{# x будет содержать строковое представление списка, а не сам список #}

{# RIGHT #}
{% macro get_cols() %}
    {% set cols = ['a', 'b'] %}
    {% do return(cols) %}
{% endmacro %}

3. Конкатенация строк через +.

{# WRONG: + для строк не работает #}
{% macro greet(name) %}
    select '{{ "Hello, " + name }}' as greeting
{% endmacro %}

{# RIGHT: используй ~ #}
{% macro greet(name) %}
    select '{{ "Hello, " ~ name }}' as greeting
{% endmacro %}

Попробуй сам

Напишите macro safe_divide(numerator, denominator, fallback=null), который генерирует CASE-выражение:

  • Если denominator = 0: возвращает fallback (default — null).
  • Иначе: возвращает numerator / denominator.

Скелет:

-- macros/safe_divide.sql

{% macro safe_divide(numerator, denominator, fallback='null') %}
    case 
        when {{ denominator }} = 0 then {{ fallback }}
        else {{ numerator }} / {{ denominator }}
    end
{% endmacro %}

Используйте в модели:

select
    customer_id,
    {{ safe_divide('total_revenue', 'order_count') }} as avg_order_value,
    {{ safe_divide('returned_amount', 'total_amount', '0') }} as return_rate
from {{ ref('marts__customers') }}

Запустите dbt compile и убедитесь, что обе вариации развернулись корректно.

Проверка знанийKnowledge check
В чём разница между implicit и explicit return в macros? Когда использовать каждый?
ОтветAnswer
Implicit return (через output тела macro): - Macro генерирует SQL-фрагмент, который подставляется в место вызова. - Вызов через ''{{ macro_name(args) }}''. - Возвращается строка — результат рендеринга тела. - Используется для генерации кусков SQL: безопасное деление, форматирование колонок, типы кастов. Explicit return (через ''{% do return(...) %}''): - Macro возвращает Python-объект: список, словарь, число. - Тело macro НЕ генерирует output. - Вызов через ''{% set x = macro_name(args) %}''. - Используется когда нужно получить значение для дальнейшей Jinja-логики: список колонок для for, словарь маппинга и т.п. Правило: если результат вставляется в SQL — implicit. Если результат используется в Jinja как переменная — explicit. Можно совмещать в одном macro: implicit return дополняется логикой через ''{% do log %}'' или ''{% set %}'', но финально либо генерируется текст, либо вызывается ''{% do return %}''. Иметь оба одновременно не получится: либо текст, либо объект.
Проверка знанийKnowledge check
Где должны лежать macro-файлы и есть ли требование к именованию файла vs имени macro внутри?
ОтветAnswer
По дефолту macros живут в каталоге macros/ в корне dbt-проекта. dbt сканирует его рекурсивно — можно создавать подкаталоги (date_helpers/, string_utils/) для группировки. Дополнительные пути настраиваются через macro-paths в dbt_project.yml. Имя файла НЕ обязано совпадать с именем macro. Внутри одного файла можно объявить несколько macros через ''{% macro %}'' ... ''{% endmacro %}''. dbt парсит файлы и регистрирует ВСЕ macros по их именам. Конвенция (best practice): - Один публичный macro в файле, имя файла = имя macro. - Helper'ы для этого macro можно положить в тот же файл, начать с _ (private convention). - Если нужны несколько связанных macros — лучше отдельный подкаталог. Имена macros во всём проекте должны быть уникальны — если объявить два macro 'foo', dbt выберет один (обычно последний по порядку сканирования) и тихо проигнорирует другой. Из-за этого важно префиксовать macros от пакета: например, dbt_utils.pivot — для разрешения конфликтов имён.

Итоги

  • Macro — переиспользуемая Jinja-функция, объявляется {% macro name(args) %} ... {% endmacro %}.
  • Лежит в каталоге macros/, сканируется рекурсивно. Имя файла не обязано совпадать с именем macro, но конвенция — один-к-одному.
  • Параметры с defaults; передача позиционная или именованная.
  • Implicit return: macro генерирует SQL-фрагмент, вызов через {{ }}.
  • Explicit return: macro возвращает Python-объект, через {% do return(...) %}, вызов через {% set x = ... %}.
  • Adapter dispatch — механизм для warehouse-специфичных реализаций (для junior — знать, что существует).
  • Best practice: имя по результату, один публичный macro в файле, docstring, не злоупотреблять.

В следующем уроке — как macros вызываются из моделей и dispatcher изнутри dbt.

Production-grade macros: design patterns для командной библиотеки

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 7. Какой синтаксис используется для объявления macro в dbt?

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

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

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

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