Control flow: if, for, set в Jinja
В прошлом уроке мы разобрали базовые блоки Jinja: expression, statement, comment. Теперь — три самых частых statements, которые делают dbt-модели динамичными: if, for, set. Каждый — про разную задачу.
set— создать или изменить переменную. Аналог:=в Python.if— условно включить/выключить кусок SQL. Аналогif/else.for— повторить кусок SQL для каждого элемента списка. Аналогforцикла.
Все три — statements, обёрнуты в {% %}, в output напрямую не попадают.
set: переменная и её жизнь
set создаёт переменную в текущей области видимости.
{% set my_table = 'orders' %}
{% set lookback_days = 7 %}
{% set columns = ['id', 'name', 'email'] %}
{% set is_prod = target.name == 'prod' %}
select * from {{ ref(my_table) }}
where order_date >= current_date - interval '{{ lookback_days }}' day
set принимает выражение справа от =. Слева — имя переменной. Тип определяется автоматически:
'orders'-> строка7-> число['id', 'name', 'email']-> списокtarget.name == 'prod'-> булево
Жизнь переменной — до конца шаблона (модели или macro). При компиляции каждой модели Jinja-окружение новое — переменные из одной модели не видны в другой.
set с блоком (multi-line)
Для длинных значений (например, SQL-фрагмент) есть форма с блоком:
{% set filter_clause %}
order_date >= current_date - interval 7 day
and status != 'cancelled'
{% endset %}
select * from {{ ref('orders') }}
where {{ filter_clause }}
Всё между {% set foo %} и {% endset %} — содержимое переменной. Удобно, когда не хочется городить escape-строки с кавычками.
if: условное включение SQL
if показывает или скрывает кусок SQL в зависимости от условия.
select *
from {{ ref('orders') }}
where 1=1
{% if target.name == 'dev' %}
and order_date >= current_date - interval 7 day
{% endif %}
При компиляции в dev target SQL станет:
select *
from "jaffle_shop"."dev"."orders"
where 1=1
and order_date >= current_date - interval 7 day
В prod target — условие исчезнет:
select *
from "jaffle_shop"."prod"."orders"
where 1=1
Это главный паттерн dev vs prod: разработчик тянет 7 последних дней (быстро), production обрабатывает всю историю. Подробнее в модуле 12.
if / else / elif
Полная форма с alternates:
{% if target.name == 'prod' %}
-- Production: вся история
where 1=1
{% elif target.name == 'staging' %}
-- Staging: последние 30 дней
where order_date >= current_date - interval 30 day
{% else %}
-- Dev / CI: последние 7 дней
where order_date >= current_date - interval 7 day
{% endif %}
Только одна ветка попадёт в output. Остальные исчезнут.
Логические операторы
Внутри if можно использовать стандартные Python-операторы:
{% if target.name == 'prod' and is_full_refresh %}
{% if not is_incremental() %}
{% if 'tag1' in tags %}
{% if my_var is not none %}
Поддерживаются: and, or, not, in, not in, is, is not. Это упрощает условия:
{% if target.name in ['dev', 'ci'] %}
-- сразу два target обрабатываем одинаково
limit 1000
{% endif %}
for: повторение SQL для списка
for идиоматично нужен в трёх местах: список колонок (для SELECT), список значений (для UNION), список табличных имён (для динамической генерации запросов).
for по списку колонок
{% set columns = ['order_id', 'order_date', 'total_amount', 'status'] %}
select
{% for col in columns %}
{{ col }} as col_{{ loop.index }}{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('orders') }}
После компиляции:
select
order_id as col_1,
order_date as col_2,
total_amount as col_3,
status as col_4
from "jaffle_shop"."main"."orders"
Обратите внимание на loop.last — это специальная переменная, которую Jinja автоматически предоставляет внутри for. Используется для запятых, чтобы не получить trailing comma.
Специальные переменные loop
Внутри {% for %} Jinja даёт несколько useful properties:
| Свойство | Что значит |
|---|---|
loop.index | Номер итерации (с 1) |
loop.index0 | Номер итерации (с 0) |
loop.first | True, если первая итерация |
loop.last | True, если последняя итерация |
loop.length | Общее число итераций |
loop.revindex | Номер с конца |
Примеры:
{% for col in columns %}
{% if loop.first %}-- First column: {% endif %}
{{ col }}
{% if not loop.last %}, {% endif %}
{% endfor %}
for + if: фильтрация
Часто хочется обработать только часть списка. Можно использовать обычный if внутри for:
{% set columns = ['id', 'name', 'password_hash', 'email'] %}
select
{% for col in columns %}
{% if 'password' not in col %}
{{ col }}{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
from {{ ref('users') }}
Или filter expression прямо в for (короче):
{% for col in columns if 'password' not in col %}
{{ col }}{% if not loop.last %},{% endif %}
{% endfor %}
Внимание: при использовании filter в for, loop.last может не сработать как ожидаешь — последний элемент до фильтра != последний элемент после фильтра. В таких случаях лучше явный if.
Вложенные циклы
Можно класть for внутрь for:
{% set models_and_cols = {'orders': ['id', 'date'], 'users': ['id', 'email']} %}
{% for model, cols in models_and_cols.items() %}
select '{{ model }}' as source_model,
{% for col in cols %}
'{{ col }}' as col_name_{{ loop.index }}{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref(model) }}
{% if not loop.last %} union all {% endif %}
{% endfor %}
{% for model, cols in dict.items() %} — итерация по словарю. Внешний loop.last — последняя пара (model, cols), внутренний — последняя колонка.
Реальный паттерн: динамический pivot
Допустим, у нас в источнике есть order_items с колонками order_id, category, amount. Хотим pivot — колонка на каждую категорию:
-- models/marts/marts__orders_pivoted.sql
{% set categories = ['electronics', 'apparel', 'food', 'books'] %}
select
order_id,
{% for cat in categories %}
sum(case when category = '{{ cat }}' then amount else 0 end) as amount_{{ cat }}
{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_jaffle__order_items') }}
group by order_id
После компиляции:
select
order_id,
sum(case when category = 'electronics' then amount else 0 end) as amount_electronics,
sum(case when category = 'apparel' then amount else 0 end) as amount_apparel,
sum(case when category = 'food' then amount else 0 end) as amount_food,
sum(case when category = 'books' then amount else 0 end) as amount_books
from "jaffle_shop"."main"."stg_jaffle__order_items"
group by order_id
Это pivot, реализованный на Jinja вручную. В dbt_utils есть готовый dbt_utils.pivot — но смысл понять механику.
Когда вы видите похожие повторяющиеся куски SQL — это знак, что нужен for. Главная ценность Jinja для junior — устранение copy-paste в SQL.
Паттерн: env-зависимая модель
-- models/staging/stg_jaffle__orders.sql
select
order_id,
customer_id,
order_date,
total_amount,
status
{% if target.name == 'dev' %}
, 'dev_mode' as env_tag
{% endif %}
from {{ source('jaffle', 'raw_orders') }}
{% if target.name == 'dev' %}
where order_date >= current_date - interval 7 day
{% endif %}
В dev: добавлена колонка env_tag и фильтр по дате. В prod: ничего лишнего, обработка всей истории.
Тонкость: whitespace control в for
Циклы часто оставляют артефакты пустых строк. Сравните:
Без подрезки:
select
{% for col in ['a', 'b', 'c'] %}
{{ col }}{% if not loop.last %},{% endif %}
{% endfor %}
from t
Output:
select
a,
b,
c
from t
С подрезкой -:
select
{%- for col in ['a', 'b', 'c'] %}
{{ col }}{% if not loop.last %},{% endif %}
{%- endfor %}
from t
Output чище:
select
a,
b,
c
from t
На junior уровне можно жить без подрезки — SQL валиден. В production-проектах подрезка считается хорошим тоном для читаемости compiled-output.
set + for: накапливаем строку
Иногда нужно динамически собрать строку. Стандартный паттерн set + цикл + append:
{% set output_cols = [] %}
{% for col in ['id', 'name', 'email'] %}
{% do output_cols.append(col ~ '::text as ' ~ col) %}
{% endfor %}
select
{{ output_cols | join(', ') }}
from {{ ref('users') }}
Здесь:
{% do output_cols.append(...) %}— выполнить метод list без вывода в output (черезdo).col ~ '::text as ' ~ col— конкатенация строк через~(Jinja, не Python+).{{ output_cols | join(', ') }}— filterjoin, превращает список в строку с разделителем.
После compile:
select
id::text as id, name::text as name, email::text as email
from "jaffle_shop"."main"."users"
Это уже продвинутый паттерн — на junior уровне достаточно for с loop.last. Но знать про ~, join, do полезно: вы встретите их в чужом коде.
Распространённые ошибки
1. Забыть endif/endfor/endset.
{# WRONG #}
{% if x %}
select 1
{# RIGHT #}
{% if x %}
select 1
{% endif %}
Jinja выдаст ошибку: Unexpected end of template.
2. Перепутать == и = в if.
{# WRONG: = это присваивание #}
{% if target.name = 'prod' %}
{# RIGHT: == это сравнение #}
{% if target.name == 'prod' %}
3. Trailing comma из-за пропуска loop.last.
{# WRONG: запятая после последней колонки #}
select
{% for col in columns %}
{{ col }},
{% endfor %}
from t
{# RIGHT #}
select
{% for col in columns %}
{{ col }}{% if not loop.last %},{% endif %}
{% endfor %}
from t
В первом случае получится select id, name, from t — SQL-ошибка.
Попробуй сам
Напишите модель marts__orders_by_status.sql. На вход: stg_jaffle__orders с колонкой status (значения: completed, pending, returned, cancelled). На выход — pivot: каждая строка = customer_id, каждая колонка = count заказов в статусе.
Подсказка к скелету:
{% set statuses = ['completed', 'pending', 'returned', 'cancelled'] %}
select
customer_id,
{% for s in statuses %}
sum(case when status = '{{ s }}' then 1 else 0 end) as count_{{ s }}
{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_jaffle__orders') }}
group by customer_id
Запустите dbt compile --select marts__orders_by_status и посмотрите в target/compiled/. Затем выполните dbt run --select marts__orders_by_status и проверьте результат в DuckDB.
Итоги
- set создаёт переменную:
{% set x = 'value' %}. Многострочная форма —{% set x %}...{% endset %}. - if/elif/else/endif — условный блок. Использует
==, не=. Поддерживаетand,or,not,in. - for/endfor — цикл по списку или dict. Внутри доступны
loop.index,loop.first,loop.last,loop.length. - for + if filter — короткая форма фильтрации:
{% for x in list if x > 0 %}. - Whitespace control через
-({%- ... -%}) — для чистого compiled-output. - Главный практический паттерн:
forустраняет copy-paste в SQL,ifуправляет dev vs prod.
В следующем уроке — самая тонкая тема Jinja в dbt: execute flag и parse phase vs execute phase. Без её понимания вы не сможете использовать run_query() и пишите багу, которая будет работать через раз.