Определение 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 %}
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 — параметр обязателен.
В 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 и убедитесь, что обе вариации развернулись корректно.
Итоги
- 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 для командной библиотеки