Learning Platform
Глоссарий Troubleshooting
Урок 04.01 · 25 мин
Продвинутый
jinjaexecuteparse-phase

Две фазы Jinja: parse (execute=False) и execute (execute=True)

Это самая частая senior-проблема в dbt: «макрос работает, потом перестаёт парситься», «model падает с TypeError на пустом dict при dbt parse», «adapter.dispatch не находит default__macro». В 90% случаев причина — непонимание двух фаз выполнения Jinja и того, какие объекты доступны в каждой.

В этом уроке мы разберём что такое execute flag, как он работает в dbt-core, и научимся писать macros, которые не ломают parse phase.


Концепт двух фаз

Когда dbt parsing’ит проект, он выполняет Jinja-код для извлечения метаданных:

  • Что такое {{ config(materialized='table') }} — нужно выполнить config() чтобы записать в Manifest
  • Что такое {{ ref('foo') }} — нужно зарегистрировать зависимость
  • Что такое {% if target.name == 'prod' %} {{ ref('a') }} {% else %} {{ ref('b') }} {% endif %} — нужно выполнить (с пустым target если parse phase нет realный) или хотя бы скипнуть

Но dbt не хочет выполнять SQL queries во время parse — это slow, requires connection, может fail если warehouse недоступен.

Решение: переменная execute в Jinja context:

  • execute = Falseparse phase. Render Jinja, но не выполнять реальный SQL. run_query(), statement(), adapter.execute() возвращают None или dummy values.
  • execute = Trueexecution phase. Реальный SQL шлётся в warehouse, results возвращаются.
{% if execute %}
  {# safe to query warehouse #}
  {% set results = run_query('SELECT 1') %}
  {{ log("got results: " ~ results, info=True) }}
{% else %}
  {# parse phase — skip real queries #}
  {{ log("parse phase, skipping", info=True) }}
{% endif %}

Senior правило: любая макро-логика, которая делает реальный SQL или зависит от warehouse state, должна быть обёрнута в {% if execute %}.

execute flag: parse phase vs execute phase (dbt I)

Где execute приходит из

В dbt-core execute — это атрибут context’а. Конкретно — ProviderContext:

# core/dbt/context/providers.py (упрощённо)
class ProviderContext(ConfiguredContext):
    @contextproperty
    def execute(self) -> bool:
        return self.provider.execute

class ParseProvider:
    execute = False  # parse phase

class RuntimeProvider:
    execute = True   # execution phase

Когда dbt парсит model, он создаёт context с ParseProvider -> execute=False. Когда выполняет model — RuntimeProvider -> execute=True.

В Jinja execute доступен глобально как переменная, не как функция:

{# Корректно #}
{% if execute %}
  ...
{% endif %}

{# Неправильно — execute() не function #}
{% if execute() %}
  ...
{% endif %}

Что доступно на parse phase

Что в Jinja context на parse vs execute
Всегда (parse + execute)Базовые объекты, доступные на обеих фазах. ref/source/config — мутируют Manifest и работают.
ref('name')Регистрирует зависимость в Manifest. Возвращает Relation object. Доступен на parse — иначе DAG не построится.
source('schema', 'name')Аналогично ref для sources. Регистрирует зависимость.
config(...)Setting model config из SQL file. Сохраняется в Manifest на parse.
var('name')Reads project vars. Available на parse — может использоваться в config(), ref() args.
env_var('NAME')Reads env. Available на parse — может быть в configs, schemas.
targetTarget info: name, type, schema, database. Available на parse — может быть в configs.
thisRelation object текущей модели. Available на parse — используется в configs (e.g. unique_key=this.something).
log()Print messages. Available always.
exceptions.raise_compiler_errorThrow error. Available always.
Только execute=TrueОбъекты, требующие connection к warehouse. На parse фазе они undefined или return dummy.
statement('main')Send SQL to warehouse, get response. На parse — undefined behavior, не вызывать.
run_query(sql)Execute arbitrary SQL, return agate.Table. На parse — returns None.
adapter.get_columns_in_relationQuery warehouse для column info. На parse — returns empty list.
load_result(name)Получить result statement по имени. На parse — None.
adapter.dispatchTricky. Available на parse для resolution macros, но adapter macros внутри могут зависеть от execute.
model objectCurrent model dict. Available на parse и execute — но с разной полнотой полей.
graph objectManifest as graph. Available после parse phase (на execute). На parse phase — partial.

Anti-pattern 1: run_query без if execute

{# WRONG #}
{% set max_date = run_query("SELECT MAX(date) FROM " ~ source('raw', 'orders')) %}
{% set max_date_str = max_date.columns[0].values()[0] %}
SELECT * FROM {{ source('raw', 'orders') }} WHERE date > '{{ max_date_str }}'

На parse phase run_query returns None. None.columns[0] -> AttributeError. dbt parse падает.

{# CORRECT #}
{% if execute %}
  {% set max_date = run_query("SELECT MAX(date) FROM " ~ source('raw', 'orders')) %}
  {% set max_date_str = max_date.columns[0].values()[0] %}
{% else %}
  {% set max_date_str = '2020-01-01' %}  {# dummy для parse #}
{% endif %}

SELECT * FROM {{ source('raw', 'orders') }} WHERE date > '{{ max_date_str }}'

На parse — max_date_str = '2020-01-01', rendered SQL valid. На execute — реальное значение из warehouse.


Anti-pattern 2: adapter calls без if execute

{# WRONG #}
{% set columns = adapter.get_columns_in_relation(ref('upstream')) %}
{% for col in columns %}
  {{ col.name }} as {{ col.name }}_aliased,
{% endfor %}

На parse get_columns_in_relation returns empty list. Result — пустой SELECT. На parse rendered SQL — invalid SQL, потенциальный fail в later validation.

{# CORRECT #}
{% if execute %}
  {% set columns = adapter.get_columns_in_relation(ref('upstream')) %}
  {% set column_names = columns | map(attribute='name') | list %}
{% else %}
  {% set column_names = [] %}
{% endif %}

SELECT
  {% if column_names %}
    {% for name in column_names %}
      {{ name }} as {{ name }}_aliased
      {%- if not loop.last %},{% endif %}
    {% endfor %}
  {% else %}
    1 as placeholder
  {% endif %}
FROM {{ ref('upstream') }}

placeholder нужен для parse-time valid SQL. На execute заменяется реальными колонками.


Anti-pattern 3: statement() в model без contexts

{# WRONG в models/my_model.sql #}
{% call statement('count_check', fetch_result=True) %}
  SELECT COUNT(*) FROM {{ ref('upstream') }}
{% endcall %}

{% set count = load_result('count_check')['data'][0][0] %}

{% if count > 1000 %}
  SELECT * FROM {{ ref('upstream') }} WHERE active = TRUE
{% else %}
  SELECT * FROM {{ ref('upstream') }}
{% endif %}

statement() в model — это only execute phase. На parse rendered модель будет странной.

Также conditional logic based on warehouse data в model body — это anti-pattern. SQL должен быть deterministic на compile time, не зависеть от warehouse state. Lead это в pre-hook или separate model.


Гибридный pattern: conditional execution

{# Сравнение row count между текущей моделью и upstream #}
{% if execute %}
  {% set this_count = run_query("SELECT COUNT(*) FROM " ~ this).columns[0].values()[0] %}
  {% set upstream_count = run_query("SELECT COUNT(*) FROM " ~ ref('upstream')).columns[0].values()[0] %}
  
  {% if this_count < upstream_count * 0.9 %}
    {{ exceptions.raise_compiler_error("Suspicious row drop: " ~ this_count ~ " vs " ~ upstream_count) }}
  {% endif %}
{% endif %}

SELECT * FROM {{ ref('upstream') }}

На parse — вся {% if execute %} секция skipped, model rendered просто как SELECT * FROM upstream. На execute — runs row count check after model executes (it’s a model body, runs during materialization).

Hmm, но это actually wrong — this table не существует на момент materialization main statement. Этот pattern лучше в post-hook. Но иллюстрирует mental model.


Pattern: adapter.dispatch с macros

{# materializations часто используют dispatch #}
{% macro my_helper(arg) %}
  {{ return(adapter.dispatch('my_helper')(arg)) }}
{% endmacro %}

{% macro default__my_helper(arg) %}
  -- generic SQL
{% endmacro %}

{% macro snowflake__my_helper(arg) %}
  -- Snowflake-specific
{% endmacro %}

adapter.dispatch('my_helper')available на parse. Резолвится в правильный adapter-specific macro based on target.type.

adapter.dispatch: multi-warehouse macros (dbt II)

На parse phase target.type known (из profiles.yml), так что dispatch работает correctly. dispatch это не runtime-only.

Но что внутри macros может быть execute-only:

{% macro snowflake__my_helper(arg) %}
  {% if execute %}
    {% set rows = run_query("SHOW TABLES LIKE '" ~ arg ~ "'") %}
    -- ...
  {% endif %}
{% endmacro %}

run_query внутри snowflake__my_helper — execute-only.


Какой context на parse phase

ProviderContext с ParseProvider:

# core/dbt/context/providers.py (упрощённо)
class ParseProvider:
    execute = False
    
    def ref(self, *args, **kwargs):
        # На parse — регистрируем dependency, возвращаем placeholder Relation
        target_unique_id = self._resolve_ref(*args, **kwargs)
        self.model.refs.append(RefArgs(*args))  # track
        return self._build_placeholder_relation(target_unique_id)
    
    def source(self, *args, **kwargs):
        # Аналогично — register dependency
        self.model.sources.append(list(args))
        return self._build_placeholder_relation(*args)
    
    def config(self, **kwargs):
        # Setting model config — mutates Manifest
        self.model.config.update_from_dict(kwargs)
    
    def adapter(self):
        # Returns ParseProvider's adapter — most methods return dummy
        return self.parse_time_adapter
    
    def statement(self, name, ...):
        # Skipped on parse — does nothing
        return None

На execute phase — RuntimeProvider:

class RuntimeProvider:
    execute = True
    
    def ref(self, *args, **kwargs):
        # На execute — returns real Relation для SQL rendering
        return self._build_full_relation(*args, **kwargs)
    
    def statement(self, name, ...):
        # Sends SQL to warehouse, stores result
        result = self.adapter.execute(sql, fetch=fetch_result)
        self._results[name] = result
        return result

Real-world: configs that depend on warehouse

Часто senior хочет сделать config dynamically:

{# модель: dim_users.sql #}
{{ config(
    materialized='incremental',
    unique_key=get_pk_from_metadata(ref('upstream'))
) }}

get_pk_from_metadata — custom macro, который запрашивает warehouse metadata. Это anti-pattern.

config() вызывается на parse phase. На parse у вас нет access к warehouse. get_pk_from_metadata — она ничего не сможет узнать.

Решение:

  1. Explicit unique_key в config — не magic.
  2. Pre-hook query + var:
    {{ config(
        materialized='incremental',
        unique_key='id'  -- explicit
    ) }}
  3. Macro that returns hardcoded based on table name:
    `{% macro get_pk(table_name) %}`
      `{% if table_name == 'dim_users' %}` user_id
      `{% elif table_name == 'dim_orders' %}` order_id
      `{% else %}` id
      `{% endif %}`
    `{% endmacro %}`
    
    `{{ config(unique_key=get_pk('dim_users')) }}`

Все эти solutions deterministic на parse phase. Никаких warehouse queries.


Дебаг execute issues

Если вы подозреваете что в макросе execute issue:

{% macro my_macro() %}
  {{ log("execute=" ~ execute, info=True) }}
  {{ log("target.name=" ~ target.name, info=True) }}
  
  {% if execute %}
    {{ log("RUNNING IN EXECUTE PHASE", info=True) }}
  {% else %}
    {{ log("RUNNING IN PARSE PHASE", info=True) }}
  {% endif %}
  
  -- остальной macro
{% endmacro %}

log(..., info=True) выводит в terminal. Запустите:

dbt parse  # видите PARSE PHASE
dbt compile --select my_model  # видите EXECUTE PHASE

Если log вышли как ожидаются — execute flag правильно tracked. Если нет — есть context issue.


Попробуй сам

  1. Создайте model с if execute pattern:
    -- models/test_execute.sql
    `{{ config(materialized='view') }}`
    
    `{% if execute %}`
      `{{ log("EXECUTE phase", info=True) }}`
    `{% else %}`
      `{{ log("PARSE phase", info=True) }}`
    `{% endif %}`
    
    SELECT 1 as id
  2. Запустите:
    dbt parse 2>&1 | grep -i "PARSE phase"
    # Должно быть: видите PARSE phase
    
    dbt run --select test_execute 2>&1 | grep -i "phase"
    # Должно быть: видите PARSE (из re-parse) и EXECUTE
  3. Попробуйте anti-pattern:
    -- модель без if execute
    `{% set result = run_query("SELECT 1") %}`
    SELECT `{{ result.columns[0].values()[0] }}` as val
    dbt parse  # должно fail с AttributeError на None.columns
  4. Fix добавлением if execute:
    `{% if execute %}`
      `{% set result = run_query("SELECT 1") %}`
      `{% set val = result.columns[0].values()[0] %}`
    `{% else %}`
      `{% set val = 0 %}`
    `{% endif %}`
    
    SELECT `{{ val }}` as val
  5. Прочитайте core/dbt/context/providers.py в клонированном dbt-core. Найдите ParseProvider и RuntimeProvider. Сравните execute свойство.

Проверка знанийKnowledge check
У вас в команде есть макрос get_relation_columns_safe(rel) который сначала пытается через adapter.get_columns_in_relation, если возвращает empty — fallback на parsing YAML. Макрос вызывается из materialization. dbt run работает, dbt parse падает с TypeError on None.iter. Что в коде проблема и как исправить sustainable?
ОтветAnswer
Корень проблемы — adapter.get_columns_in_relation returns None или empty list на parse phase (execute=False). Когда вы итерируете 'for col in columns: ...' и columns is None, TypeError. Macro вызывается из materialization, но materializations *тоже* parse'ятся (для validation что они существуют и имеют правильную сигнатуру), хотя их main body выполняется только на execute. Sustainable fix: (1) Wrap в if execute: внутри get_relation_columns_safe сначала {% if execute %} {% set cols = adapter.get_columns_in_relation(rel) %} {% else %} {% set cols = [] %} {% endif %}. На parse возвращает empty list, на execute — реальные columns. (2) Защитить итерацию: {% for col in (columns or []) %} — даже если None, не fail. Defensive coding. (3) Если макрос *должен* возвращать non-empty result для logic в materialization — раскладывается на parse-time-safe path. Например, на parse phase возвращайте dummy ['placeholder_col']. На execute — реальный list. Logic в materialization должен tolerate "empty" parse-phase output. (4) Структурный fix: rethink design. Если materialization логика зависит от warehouse metadata, это сложный case. Альтернатива — explicit columns в model.yml config, не runtime introspection. (5) Add log: {{ log("get_relation_columns_safe: execute=" ~ execute ~ ", returned " ~ cols | length ~ " cols", info=True) }}. Тогда в next run вы сразу видите на какой phase macro работает. Sustainable pattern: ВСЕГДА wrap'ить warehouse-dependent macros в if execute, всегда возвращать sensible default из else-branch (empty list, 0, '', not None). Это makes macros safe to call from any context.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Senior пишет макрос, использующий run_query() для определения incremental boundary. dbt parse падает с AttributeError 'NoneType' object has no attribute 'columns'. Что не так и как исправить?

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

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

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

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