Две фазы 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 = False— parse phase. Render Jinja, но не выполнять реальный SQL.run_query(),statement(),adapter.execute()возвращают None или dummy values.execute = True— execution 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 приходит из
В 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
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.
На 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 — она ничего не сможет узнать.
Решение:
- Explicit unique_key в config — не magic.
- Pre-hook query + var:
{{ config( materialized='incremental', unique_key='id' -- explicit ) }} - 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.
Попробуй сам
- Создайте 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 - Запустите:
dbt parse 2>&1 | grep -i "PARSE phase" # Должно быть: видите PARSE phase dbt run --select test_execute 2>&1 | grep -i "phase" # Должно быть: видите PARSE (из re-parse) и EXECUTE - Попробуйте anti-pattern:
-- модель без if execute `{% set result = run_query("SELECT 1") %}` SELECT `{{ result.columns[0].values()[0] }}` as valdbt parse # должно fail с AttributeError на None.columns - 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 - Прочитайте
core/dbt/context/providers.pyв клонированном dbt-core. НайдитеParseProviderиRuntimeProvider. Сравнитеexecuteсвойство.