Современный семантический слой на dbt + MetricFlow + DuckDB: макросы вместо пакетов
В этой статье мы построим полностью рабочий семантический слой для аналитики на данных Superstore.csv (от Tableau Desktop, можно скачать его где-то), используя dbt, MetricFlow и DuckDB. Вместо устаревшего пакета `dbt_metrics` (который несовместим с dbt 1.12+), мы применим современный подход — вынесем логику метрик в макросы dbt. Это позволяет централизованно управлять расчётами, автоматически обновлять витрины при изменении метрик и использовать семантический слой для ad-hoc-запросов через `mf`. Все шаги проверены на последних версиях инструментов и готовы к использованию в реальных проектах.
1. Зачем нужен семантический слой и почему макросы — это современный подход?
Семантический слой решает ключевые проблемы аналитики:
- Единое место определения метрик — бизнес-логика хранится в одном файле.
- Автоматическая генерация SQL — не нужно писать `GROUP BY`, `JOIN` и агрегации вручную для ad-hoc-запросов.
- Согласованность — аналитики и BI-инструменты используют одни и те же метрики.
Ранее для использования метрик в моделях применялся пакет `dbt_metrics`, но он несовместим с dbt 1.12 и выше (требует версию <1.6.0). Современный подход — **выносить выражения метрик в макросы dbt**. Это:
- Не требует внешних пакетов.
- Работает с любой версией dbt.
- Даёт полный контроль над SQL.
- Легко поддерживается и версионируется.
Мы будем использовать:
- dbt 1.12.4 — для управления моделями и витринами.
- MetricFlow (встроенный в dbt) — для семантического слоя и ad-hoc-запросов через `mf`.
- DuckDB 1.5.5 — лёгкая БД для разработки.
- Макросы dbt — для переиспользования логики метрик в витринах.
2. Установка инструментов и создание проекта
Используем `uv` — быстрый менеджер пакетов Python.
mkdir dbtflow_duck
cd dbtflow_duck
uv init
uv venv
source .venv/bin/activate
uv pip install dbt-duckdb dbt-metricflow pandas duckdbПроверяем версии (на момент написания):
- dbt-core: 1.12.4
- duckdb: 1.5.5
- metricflow: 0.212.0
3. Загрузка данных Superstore
Скачайте `Superstore.csv` (например, с Kaggle) и поместите в корень проекта.
Создайте `load_data.py`:
import pandas as pd
import duckdb
df = pd.read_csv('Superstore.csv', sep='\t', encoding='latin1')
df.columns = [col.lower().replace(' ', '_').replace('/', '_').replace('-', '_') for col in df.columns]
for col in ['sales', 'discount', 'profit']:
if col in df.columns:
df[col] = df[col].astype(str).str.replace(',', '.').astype(float)
df['order_date'] = pd.to_datetime(df['order_date'], dayfirst=True)
df['ship_date'] = pd.to_datetime(df['ship_date'], dayfirst=True)
df['postal_code'] = df['postal_code'].astype(str)
db_path = 'dbtflow_duck.duckdb'
con = duckdb.connect(db_path)
con.execute("DROP TABLE IF EXISTS superstore")
con.execute("""
CREATE TABLE superstore (
row_id INTEGER, order_id VARCHAR, order_date DATE, ship_date DATE,
ship_mode VARCHAR, customer_id VARCHAR, customer_name VARCHAR, segment VARCHAR,
country_region VARCHAR, city VARCHAR, state VARCHAR, postal_code VARCHAR,
region VARCHAR, product_id VARCHAR, category VARCHAR, sub_category VARCHAR,
product_name VARCHAR, sales DOUBLE, quantity INTEGER, discount DOUBLE, profit DOUBLE
)
""")
con.execute("INSERT INTO superstore SELECT * FROM df")
print(f"✅ Загружено {len(df)} строк в {db_path}")
con.close()Запуск:
python load_data.py4. Инициализация dbt-проекта вручную
Создаём папку проекта и файлы конфигурации (без `dbt init`, чтобы избежать проблем):
mkdir dbt_project
cd dbt_projectdbt_project.yml:
name: 'dbt_project'
version: '1.0.0'
profile: 'dbt_project'
model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]
clean-targets:
- "target"
- "dbt_packages"
models:
dbt_project:
+materialized: tableprofiles.yml:
dbt_project:
target: dev
outputs:
dev:
type: duckdb
path: ../dbtflow_duck.duckdb
schema: main
threads: 4Создаём структуру папок:
mkdir -p models analyses tests seeds macros snapshots
export DBT_PROFILES_DIR=$(pwd)
dbt debug # должно быть All checks passed!5. Модели данных
models/sources.yml:
version: 2
sources:
- name: default
schema: main
tables:
- name: superstoremodels/superstore_clean.sql:
{{ config(materialized='table') }}
SELECT
row_id,
order_id,
order_date,
ship_date,
ship_mode,
customer_id,
customer_name,
segment,
country_region,
city,
state,
postal_code,
region,
product_id,
category,
sub_category,
product_name,
sales,
quantity,
discount,
profit
FROM {{ source('default', 'superstore') }}models/time_spine.sql:
{{ config(materialized='table') }}
SELECT
CAST(generate_series AS DATE) AS date_day
FROM generate_series(DATE '2020-01-01', DATE '2026-12-31', INTERVAL '1' DAY)models/metricflow_time_spine.yml (обязательно для MetricFlow):
models:
- name: time_spine
description: "A time spine with one row per day for MetricFlow."
time_spine:
standard_granularity_column: date_day
columns:
- name: date_day
granularity: day6. Семантическая модель и метрики
models/superstore.yml:
semantic_models:
- name: superstore
model: ref('superstore_clean')
description: "Superstore sales data"
defaults:
agg_time_dimension: order_date
entities:
- name: order_id
type: primary
dimensions:
- name: order_date
type: time
type_params:
time_granularity: day
- name: category
type: categorical
- name: region
type: categorical
measures:
- name: total_sales
agg: sum
expr: sales
- name: total_profit
agg: sum
expr: profit
- name: total_discount
agg: average # Внимание: не "avg", а "average"!
expr: discount
metrics:
- name: total_sales
label: Total Sales
type: simple
type_params:
measure: total_sales
- name: total_profit
label: Total Profit
type: simple
type_params:
measure: total_profit
- name: avg_discount
label: Average Discount
type: simple
type_params:
measure: total_discount
- name: profit_margin
label: Profit Margin
type: derived
type_params:
expr: total_profit / total_sales
metrics:
- total_profit
- total_salesВыполняем сборку и генерацию артефактов:
dbt run
dbt parse7. Ad-hoc-запросы через MetricFlow CLI (`mf`)
Проверяем конфигурацию:
mf validate-configsСписок метрик:
mf list metricsВывод:
• avg_discount: metric_time, order_id__category, order_id__order_date, order_id__region
• profit_margin: metric_time, order_id__category, order_id__order_date, order_id__region
• total_profit: metric_time, order_id__category, order_id__order_date, order_id__region
• total_sales: metric_time, order_id__category, order_id__order_date, order_id__regionПримеры запросов:
mf query --metrics total_sales --group-by order_id__category
mf query --metrics profit_margin --group-by order_id__region
mf query --metrics avg_discount --group-by order_id__category
MetricFlow генерирует SQL автоматически — мы не пишем ни строчки кода для этих запросов.
8. Современный подход: макросы для переиспользования метрик в витринах
Вместо устаревшего пакета `dbt_metrics` мы создадим макросы, которые возвращают SQL-выражение для каждой метрики. Это позволяет:
- Централизованно управлять логикой расчёта.
- Использовать одну и ту же логику во всех витринах.
- Легко изменять метрику в одном месте.
macros/get_metric_expr.sql:
{% macro get_total_sales_expr() %}
SUM(sales)
{% endmacro %}
{% macro get_total_profit_expr() %}
SUM(profit)
{% endmacro %}
{% macro get_avg_discount_expr() %}
AVG(discount)
{% endmacro %}
{% macro get_profit_margin_expr() %}
{{ get_total_profit_expr() }} / NULLIF({{ get_total_sales_expr() }}, 0)
{% endmacro %}Теперь мы можем строить витрины, используя эти макросы.
models/sales_by_category.sql:
{{ config(materialized='table') }}
SELECT
category,
{{ get_total_sales_expr() }} AS total_sales
FROM {{ ref('superstore_clean') }}
GROUP BY categorymodels/profit_by_region.sql:
{{ config(materialized='table') }}
SELECT
region,
{{ get_total_profit_expr() }} AS total_profit,
{{ get_profit_margin_expr() }} AS profit_margin
FROM {{ ref('superstore_clean') }}
GROUP BY regionmodels/avg_discount_by_category.sql:
{{ config(materialized='table') }}
SELECT
category,
{{ get_avg_discount_expr() }} AS avg_discount
FROM {{ ref('superstore_clean') }}
GROUP BY categoryПосле добавления новых моделей выполняем `dbt run` — все витрины создаются с актуальной логикой метрик.
9. Преимущества подхода с макросами
- Совместимость — работает с любой версией dbt, без внешних пакетов.
- Гибкость — можно легко добавлять фильтры, условия, использовать оконные функции.
- Прозрачность — SQL-код виден и контролируется, его легко отлаживать.
- Единый источник истины — метрики определены как в YAML (для `mf`), так и в макросах (для витрин).
- Автоматизация — изменения в макросах автоматически обновляют все витрины при следующем `dbt run`.
10. Расширенный пример: добавление фильтра в метрику
Предположим, нам нужна выручка только для категории “Technology”. Мы можем создать отдельный макрос:
{% macro get_tech_sales_expr() %}
SUM(CASE WHEN category = 'Technology' THEN sales ELSE 0 END)
{% endmacro %}И использовать его в витрине:
{{ config(materialized='table') }}
SELECT
region,
{{ get_tech_sales_expr() }} AS tech_sales
FROM {{ ref('superstore_clean') }}
GROUP BY regionЭто демонстрирует, насколько легко расширять систему без изменения основной семантической модели.
11. Автоматизация пайплайнов
Настройте регулярный запуск `dbt run` в CI/CD или Airflow. При каждом запуске:
- Обновляются сырые данные (если они загружаются заново).
- Пересчитываются модели `superstore_clean` и `time_spine`.
- Пересчитываются все витрины с актуальной логикой метрик из макросов.
Таким образом, ваши отчёты всегда актуальны, а бизнес-логика централизована.
12. Заключение
Мы построили современный семантический слой на стеке dbt + MetricFlow + DuckDB, используя макросы для переиспользования логики метрик в витринах. Этот подход:
- Не требует устаревших пакетов.
- Совместим с последними версиями dbt.
- Даёт полный контроль над SQL.
- Легко масштабируется и поддерживается.
Все команды проверены и работают на практике. Вы можете адаптировать этот проект под свои данные и метрики, получая все преимущества семантического слоя без лишних зависимостей.
Полезные ссылки: