Macros Avançadas em Jinja2 e Materializações Customizadas
Escreva macros Jinja2 de nível profissional para padrões SQL DRY, construa bibliotecas de macros reutilizáveis e crie materializações customizadas para padrões específicos do Redshift como logs de eventos append-only e refresh de tabelas externas.
Macros Avançadas em Jinja2 e Materializações Customizadas
Jinja2 é o motor de templates do dbt — ele transforma SQL de uma linguagem de consulta estática em uma linguagem dinâmica e composável. Dominar Jinja2 é a diferença entre um projeto que escala para 500 modelos com uma equipe pequena e um que se torna insustentável. Este módulo cobre padrões de macro de nível profissional e materializações customizadas para casos de uso específicos do Redshift.
O Jinja2 permite que você escreva SQL parametrizado, com loops, condicionais e reutilização de código. Sem ele, você teria que copiar e colar SQL repetitivo centenas de vezes, tornando a manutenção um pesadelo.
Jinja2 no dbt: O que Está Disponível
O dbt estende o Jinja2 com suas próprias funções e objetos. As três camadas são:
┌─────────────────────────────────────┐
│ Funções específicas do dbt │
│ ref(), source(), config(), this │
│ run_query(), log(), execute │
├─────────────────────────────────────┤
│ Jinja2 padrão │
│ {% if %}, {% for %}, {% set %} │
│ filters, tests, controle de whitespace │
├─────────────────────────────────────┤
│ Built-ins Python expostos pelo Jinja2│
│ range(), namespace(), loop.* │
└─────────────────────────────────────┘
Fundamentos de Macro: Além do Básico
Escopo de Variáveis com namespace()
Variáveis de loop do Jinja2 não atualizam o escopo externo sem namespace():
-- macros/utils/string_utils.sql
-- ERRADO: result é sempre string vazia após o loop
{% macro join_columns_wrong(columns) %}
{% set result = '' %}
{% for col in columns %}
{% set result = result ~ col %} {# Isto NÃO atualiza result externo #}
{% endfor %}
{{ result }}
{% endmacro %}
-- CORRETO: use namespace() para estado mutável dentro de loops
{% macro join_columns(columns, separator=', ') %}
{% set ns = namespace(parts=[]) %}
{% for col in columns %}
{% set ns.parts = ns.parts + [col] %}
{% endfor %}
{{ ns.parts | join(separator) }}
{% endmacro %}Retornando Valores de Macros
-- macros/utils/get_column_names.sql
{% macro get_column_names(relation) %}
{% set query %}
select column_name
from information_schema.columns
where table_schema = '{{ relation.schema }}'
and table_name = '{{ relation.name }}'
order by ordinal_position
{% endset %}
{% set results = run_query(query) %}
{% if execute %}
{% set columns = results.columns[0].values() %}
{{ return(columns) }}
{% else %}
{{ return([]) }}
{% endif %}
{% endmacro %}Usando o valor retornado:
-- models/marts/facts/fct_orders.sql
{% set cols = get_column_names(ref('stg_orders')) %}
select
{% for col in cols %}
{{ col }}{% if not loop.last %},{% endif %}
{% endfor %}
from {{ ref('stg_orders') }}Biblioteca de Macros de Produção para Redshift
1. Union Dinâmico de Múltiplas Fontes
-- macros/redshift/union_relations.sql
{% macro union_relations(relations, col_names=none) %}
{%- for relation in relations -%}
select
{%- if col_names is not none -%}
{%- for col in col_names %}
{{ col }}{% if not loop.last %},{% endif %}
{%- endfor %}
{%- else -%}
*
{%- endif %}
from {{ relation }}
{% if not loop.last %}union all{% endif %}
{%- endfor -%}
{% endmacro %}Uso:
-- models/marts/fct_all_events.sql
{{ union_relations([
ref('fct_web_events'),
ref('fct_mobile_events'),
ref('fct_api_events')
]) }}2. Macro Pivot (Seguro para Redshift)
O Redshift não tem um PIVOT nativo. Esta macro gera o padrão CASE/SUM:
-- macros/redshift/pivot.sql
{% macro pivot(
column,
values,
alias=true,
agg='sum',
then_value=1,
else_value=0,
quote_identifiers=false,
distinct=false
) %}
{%- for value in values %}
{{ agg }}(
{%- if distinct %} distinct {% endif -%}
case
when {{ column }} = '{{ value }}'
then {{ then_value }}
else {{ else_value }}
end
)
{%- if alias %} as {% if quote_identifiers %}"{{ value }}"{% else %}{{ value | replace(' ', '_') | lower }}{% endif %}{% endif %}
{%- if not loop.last %},{%- endif %}
{%- endfor %}
{% endmacro %}-- models/marts/fct_order_status_pivot.sql
select
report_date,
{{ pivot(
column='status',
values=['Pending', 'Shipped', 'Cancelled', 'Rejected'],
agg='count'
) }}
from {{ ref('fct_orders') }}
group by 13. Gerar Date Spine
-- macros/redshift/date_spine.sql
{% macro date_spine(start_date, end_date, datepart='day') %}
with date_series as (
select
(
'{{ start_date }}'::date
+ generate_series(
0,
datediff(
'{{ datepart }}',
'{{ start_date }}'::date,
'{{ end_date }}'::date
)
) * interval '1 {{ datepart }}'
)::date as date_day
)
select date_day from date_series
{% endmacro %}-- models/marts/dim_date.sql
{{ config(materialized='table', dist='all', sort='date_day') }}
{{ date_spine(
start_date='2020-01-01',
end_date='2030-12-31',
datepart='day'
) }}4. Macro UNLOAD do Redshift para S3
-- macros/redshift/unload_to_s3.sql
{% macro unload_to_s3(
relation,
s3_path,
iam_role,
format='PARQUET',
partition_by=none,
max_file_size='6.2 GB',
parallel=true
) %}
{% set query %}
unload (
'select * from {{ relation }}'
)
to '{{ s3_path }}'
iam_role '{{ iam_role }}'
format {{ format }}
{% if partition_by %}
partition by ({{ partition_by | join(', ') }})
{% endif %}
allowoverwrite
maxfilesize '{{ max_file_size }}'
{% if parallel %}parallel on{% else %}parallel off{% endif %};
{% endset %}
{% if execute %}
{% do run_query(query) %}
{{ log("Unloaded " ~ relation ~ " to " ~ s3_path, info=true) }}
{% endif %}
{% endmacro %}Uso como operação:
dbt run-operation unload_to_s3 --args "{
relation: 'analytics.marts.fct_orders',
s3_path: 's3://my-data-lake/exports/fct_orders/',
iam_role: 'arn:aws:iam::123456789012:role/RedshiftS3Role',
format: 'PARQUET',
partition_by: ['order_date']
}"5. Macro de Recomendação de Sort Keys Adaptativa
-- macros/redshift/recommend_sort_keys.sql
{% macro recommend_sort_keys(schema, table, limit=10) %}
{% set query %}
select
schemaname,
tablename,
"column" as column_name,
usename as query_count,
plannode
from svl_qlog
join pg_user using (usesysid)
where schemaname = '{{ schema }}'
and tablename = '{{ table }}'
order by query_count desc
limit {{ limit }}
{% endset %}
{% if execute %}
{% set results = run_query(query) %}
{% for row in results.rows %}
{{ log(row, info=true) }}
{% endfor %}
{% endif %}
{% endmacro %}Materializações Customizadas
Materializações customizadas permitem definir novos padrões de persistência além dos integrados do dbt. São blocos Jinja2 armazenados em macros/materializations/.
Anatomia de uma Materialização Customizada
-- macros/materializations/append_only_table.sql
{% materialization append_only_table, adapter='redshift' %}
{# Configuração obrigatória #}
{%- set target_relation = this.incorporate(type='table') -%}
{# Obter o adapter de execução #}
{%- set existing_relation = load_cached_relation(this) -%}
{# Executar pre-hooks #}
{{ run_hooks(pre_hooks) }}
{# Criar tabela na primeira execução #}
{% if existing_relation is none %}
{% call statement('main') %}
{{ create_table_as(false, target_relation, sql) }}
{% endcall %}
{# Append nas execuções subsequentes #}
{% else %}
{% call statement('main') %}
insert into {{ target_relation }}
({{ sql }})
{% endcall %}
{% endif %}
{# Executar post-hooks #}
{{ run_hooks(post_hooks) }}
{# Atualizar o cache de relations #}
{{ return({'relations': [target_relation]}) }}
{% endmaterialization %}Usando a materialização customizada:
-- models/raw/raw_click_stream.sql
{{ config(
materialized='append_only_table',
dist='session_id',
sort=['event_timestamp'],
sort_type='compound',
backup=false
) }}
select
session_id,
user_id,
event_timestamp,
page_url,
referrer
from {{ source('raw', 'click_stream') }}
where loaded_at > (
select coalesce(
max(event_timestamp)::timestamp,
'1970-01-01'::timestamp
)
from {{ this }}
)Materialização Customizada: Refresh de Tabela Externa Redshift Spectrum
Para tabelas externas do Redshift Spectrum que precisam de refresh periódico de metadados:
-- macros/materializations/spectrum_external_table.sql
{% materialization spectrum_external_table, adapter='redshift' %}
{%- set external_schema = config.require('external_schema') -%}
{%- set s3_location = config.require('s3_location') -%}
{%- set file_format = config.get('file_format', 'parquet') -%}
{%- set partition_cols = config.get('partition_cols', []) -%}
{%- set table_properties = config.get('table_properties', {}) -%}
{%- set target_relation = this.incorporate(type='table') -%}
{{ run_hooks(pre_hooks) }}
-- Drop e recria a definição da tabela externa
{% call statement('main') %}
{% if load_cached_relation(this) is not none %}
drop table if exists {{ external_schema }}.{{ this.identifier }};
{% endif %}
create external table {{ external_schema }}.{{ this.identifier }}
({{ sql }})
{% if partition_cols %}
partitioned by ({{ partition_cols | join(', ') }})
{% endif %}
stored as {{ file_format }}
location '{{ s3_location }}';
{% if partition_cols %}
-- Auto-descobrir partições
msck repair table {{ external_schema }}.{{ this.identifier }};
{% endif %}
{% endcall %}
{{ run_hooks(post_hooks) }}
{{ return({'relations': [target_relation]}) }}
{% endmaterialization %}Teste e Documentação de Macros
Documentando Macros
# macros/schema.yml
macros:
- name: pivot
description: >
Gera uma tabela pivot usando expressões CASE/SUM. Compatível com Redshift.
Use em vez do PIVOT nativo que o Redshift não suporta.
arguments:
- name: column
type: string
description: "A coluna para pivotear"
- name: values
type: list
description: "Valores distintos para transformar em colunas"
- name: agg
type: string
description: "Função de agregação (padrão: sum)"
- name: alias
type: boolean
description: "Se deve aliasear colunas geradas (padrão: true)"
- name: unload_to_s3
description: "UNLOAD de uma relation para S3 em formato Parquet ou CSV"
arguments:
- name: relation
type: relation
description: "A relação dbt para exportar"
- name: s3_path
type: string
description: "Caminho de destino S3 (deve terminar com /)"
- name: iam_role
type: string
description: "ARN da IAM Role com permissões de escrita S3 e UNLOAD Redshift"Testando Macros via dbt run-operation
# Testar a saída da macro date_spine
dbt run-operation date_spine --args "{'start_date': '2024-01-01', 'end_date': '2024-01-07'}"
# Testar macro UNLOAD em modo dry-run (verificar compilação SQL)
dbt compile --select fct_orders # garantir que o modelo compila primeiro6 Perguntas de Prática
Por que `namespace()` é necessário ao atualizar uma variável dentro de um loop `{% for %}` no Jinja2?
Em uma materialização customizada, o que `config.require('my_param')` faz de diferente de `config.get('my_param')`?
A assinatura `{% materialization my_mat, adapter='redshift' %}` significa o quê?
Você quer exportar (UNLOAD) uma tabela Redshift para S3 como Parquet, particionada por `order_date`. Qual mecanismo dbt é mais apropriado?
Qual proteção é essencial em torno de chamadas `run_query()` em macros para evitar execução durante as fases de parse ou compilação do dbt?
Onde os arquivos de materialização customizada devem ser armazenados em um projeto dbt?
Principais Conclusões
- Use
namespace()para estado mutável dentro de loops{% for %}no Jinja2 — atribuição simples de variável não escapa do escopo do loop. - Sempre proteja chamadas
run_query()com{% if execute %}para prevenir execução durante fases de parse/compilação. config.require()levanta erro de compilação para configs ausentes;config.get()retorna None ou um padrão — use require() para parâmetros obrigatórios de materialização.- A assinatura
adapter='redshift'em{% materialization %}cria uma implementação específica para Redshift; outros adapters usam a implementaçãodefault. - UNLOAD para S3 é melhor implementado como uma macro post-hook, mantendo a lógica de transformação separada da etapa de exportação.
- Documente macros em
macros/schema.yml— elas aparecem emdbt docse podem ser testadas viadbt run-operation.