Welcome to my personal place for love, peace and happiness 🤖

Современный семантический слой на 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.py

4. Инициализация dbt-проекта вручную

Создаём папку проекта и файлы конфигурации (без `dbt init`, чтобы избежать проблем):

mkdir dbt_project
cd dbt_project

dbt_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: table

profiles.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: superstore

models/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: day

6. Семантическая модель и метрики

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 parse

7. 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 category

models/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 region

models/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.
  • Легко масштабируется и поддерживается.

Все команды проверены и работают на практике. Вы можете адаптировать этот проект под свои данные и метрики, получая все преимущества семантического слоя без лишних зависимостей.

Полезные ссылки:


Follow this blog
Send
Share
Tweet
Pin