Learning Platform
Глоссарий Troubleshooting
Урок 11.02 · 22 мин
Начальный
dbtJinjacontrol flowloops

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.firstTrue, если первая итерация
loop.lastTrue, если последняя итерация
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 — но смысл понять механику.

TIP

Когда вы видите похожие повторяющиеся куски 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(', ') }} — filter join, превращает список в строку с разделителем.

После 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.

Проверка знанийKnowledge check
Зачем нужна переменная loop.last в Jinja-циклах? Что произойдёт, если её забыть?
ОтветAnswer
loop.last — это True, если текущая итерация цикла последняя. Используется для условного вывода разделителей (запятых, AND/OR, UNION) ПЕРЕД последним элементом. Типовой паттерн в SELECT-списке: ''{% for col in columns %}'' ''{{ col }}'''{% if not loop.last %}',''{% endif %}'' ''{% endfor %}'' Запятая ставится после каждой колонки, кроме последней. Это даёт корректный SQL: 'a, b, c' (без trailing comma). Что произойдёт без loop.last: получится 'a, b, c,' с запятой после последней колонки. Дальше идёт 'from t' — SQL станет невалидным ('select a, b, c, from t'). Запрос упадёт с ошибкой syntax error near 'from'. Аналогично для UNION: loop.last используется чтобы не ставить лишний 'union all' в конце. Альтернатива — формы с join: ''{{ columns | join(', ') }}''. Чище для коротких списков, но при сложной логике (case when и т.п.) проще классический for с loop.last.
Проверка знанийKnowledge check
Чем set отличается от `{% do %}` в Jinja? Когда использовать каждый?
ОтветAnswer
set создаёт или переопределяет переменную. Синтаксис: {% set x = value %}. Возвращаемое значение присваивается имени x. Используется, когда нужно сохранить результат для дальнейшего использования. do выполняет выражение БЕЗ присваивания результата куда-либо. Синтаксис: ''{% do x.append(item) %}''. Полезен для мутирующих методов: list.append, dict.update — они меняют объект in-place, и присваивать их результат не нужно (они возвращают None в Python). Когда что: - ''{% set columns = ['a', 'b'] %}'' — создаём переменную columns со значением. - ''{% set my_var = some_func() %}'' — присваиваем результат функции. - ''{% do my_list.append('item') %}'' — добавляем элемент в существующий список, ничего не присваиваем. - ''{% do log('hello', info=true) %}'' — вызываем функцию ради побочного эффекта (запись в лог), результат не нужен. set ВСЕГДА присваивает, do НИКОГДА не присваивает. Если попытаться использовать do без понимания: ''{% do x = 5 %}'' — это будет ошибкой синтаксиса.

Итоги

  • 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() и пишите багу, которая будет работать через раз.

Jinja в Airflow: macros, user_defined_macros, template_fields

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 7. Какой statement создаёт переменную в Jinja?

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

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

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

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