From dfc5748358a5945f2d17794b6f91827718605158 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Fri, 7 Nov 2025 11:05:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B8=D1=81=D1=82=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D0=B8=20+=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- dwh-modeling/README.md | 663 ++++++++++++++++++ SCD.md => dwh-modeling/SCD.md | 0 dwh-modeling/data/customers.csv | 4 + dwh-modeling/data/order_items.csv | 4 + dwh-modeling/data/orders.csv | 3 + dwh-modeling/data/prices.csv | 4 + dwh-modeling/data/products.csv | 3 + dwh-modeling/dimensions_sample.sql | 255 +++++++ .../dwh-modeling-basics.md | 0 .../dwh-modeling-ds.md | 0 dwh-modeling/dwh-modeling-plan3.md | 570 +++++++++++++++ dwh-modeling/sql/01_ddl.sql | 125 ++++ dwh-modeling/sql/02_dim.sql | 182 +++++ dwh-modeling/sql/03_validation.sql | 98 +++ 15 files changed, 1912 insertions(+), 1 deletion(-) create mode 100644 dwh-modeling/README.md rename SCD.md => dwh-modeling/SCD.md (100%) create mode 100644 dwh-modeling/data/customers.csv create mode 100644 dwh-modeling/data/order_items.csv create mode 100644 dwh-modeling/data/orders.csv create mode 100644 dwh-modeling/data/prices.csv create mode 100644 dwh-modeling/data/products.csv create mode 100644 dwh-modeling/dimensions_sample.sql rename dwh-modeling-basics.md => dwh-modeling/dwh-modeling-basics.md (100%) rename dwh-modeling-ds.md => dwh-modeling/dwh-modeling-ds.md (100%) create mode 100644 dwh-modeling/dwh-modeling-plan3.md create mode 100644 dwh-modeling/sql/01_ddl.sql create mode 100644 dwh-modeling/sql/02_dim.sql create mode 100644 dwh-modeling/sql/03_validation.sql diff --git a/README.md b/README.md index 7a1e48d..6041c23 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ - [Яндекс Практикум: что такое нормализация, простыми словами (для самых начинающих)](https://practicum.yandex.ru/blog/chto-takoe-normalizaciya-dannyh/) - [Базы данных. 1,2,3 нормальные формы. - Youtube](https://www.youtube.com/watch?v=zwQzL80U51c) - Краткая теория про [DWH](https://halltape.github.io/HalltapeRoadmapDE/DWH/) -- Теория про Slowly Changing Dimensions: [SCD](SCD.md) +- Теория про Slowly Changing Dimensions: [SCD](dwh-modeling/SCD.md) - Хорошее общее введение в модели данных дано в статье и докладе от Yandex: [Как мы внедрили свою модель хранения данных — highly Normalized hybrid Model. Доклад Яндекса](https://habr.com/ru/companies/yandex/articles/557140/) diff --git a/dwh-modeling/README.md b/dwh-modeling/README.md new file mode 100644 index 0000000..007c737 --- /dev/null +++ b/dwh-modeling/README.md @@ -0,0 +1,663 @@ +Отлично! Вот **связный, литературный и педагогически выстроенный** вариант статьи, адаптированный под новичков (студентов с базовым SQL), но без упрощения сути. Я убрал «рыхлые» места, усилил плавность повествования, добавил пояснения «на пальцах» — и сохранил все важные детали: архитектурные слои, модели данных, SCD, ключи, антипаттерны. + +Статья оформлена как **обучающий материал** — с логикой «от боли к решению», с живым примером (интернет-магазин), с акцентом на *почему*, а не только *как*. +Готова к публикации на Habr, с приложенным GitHub-репозиторием. + +--- + +# **Хранилище данных: как устроена аналитика «под капотом»** +*Для тех, кто знает SQL, но хочет понять, как хранить данные не в Excel, а по-взрослому* + +--- + +## **Что вы уже умеете — и что узнаете здесь** + +✅ Уже знаете: +- `SELECT`, `JOIN`, `GROUP BY`; +- как посчитать сумму/среднее/количество по таблице. + +🆕 Узнаете в этой статье: +- **слои хранилища** (STG → ODS → DDS → DM) и *зачем они нужны*; +- **факты и измерения** — основные кирпичики аналитики; +- **SCD Type 2** — как хранить историю изменений клиента (например, смену email или города); +- **суррогатные ключи (SK)** и чем они отличаются от обычных `id`; +- **четыре модели данных**: 3NF, Звезда (Star), Data Vault, Anchor Modeling — и когда какую использовать. + +⛔ **Не будем говорить** здесь о: +- физическом хранении (партиции, индексы, ClickHouse-движки); +- распределённых кластерах (Kafka, Spark, Airflow — это отдельный курс); +- настройке производительности (`EXPLAIN`, кэши и т.п.). +Это — про *логику*, структуру и здравый смысл. + +--- + +## **1. Введение: почему нельзя просто SELECT из базы заказов?** + +Представьте: вы — аналитик в интернет-магазине. Вам нужно ответить на вопрос: +> **«Сколько заказов сделал клиент с email `a@ex.com` за 2023 год, и сколько он потратил?»** + +Вы идёте в базу заказов — и… не находите email. Он в CRM. Идёте в CRM — там нет сумм заказов. Возвращаетесь в заказы — сумма есть, но *только текущая цена товара*. А в 2023 году цена была другой! + +Знакомо? Это — **проблема OLTP-систем** (оперативного учёта): +- **CRM**, **склад**, **платёжка** — это разные базы; +- каждая оптимизирована под *быструю запись операций* («добавить заказ», «списать товар»); +- историю там не хранят — email меняется «в лоб»: старое значение перезаписывается. + +Такие системы называют **OLTP** (*Online Transaction Processing* — обработка транзакций в реальном времени). +А для аналитики нужна **OLAP** (*Online Analytical Processing* — обработка запросов на анализ). + +➡️ **Хранилище данных (Data Warehouse, DWH)** — это как «единая карта сокровищ», куда собирают данные из всех источников, *сохраняя историю*, *выравнивая термины* и *готовя их к анализу*. + +И вот главный секрет его успеха: **слоистая архитектура**. + +--- + +## **2. Учебный пример: интернет-магазин** + +Чтобы всё было на пальцах — разберём простой, но живой пример. + +У нас есть 6 таблиц из трёх источников: + +| Таблица | Источник | Что содержит | +|---------|----------|--------------| +| `customers` | CRM | Клиенты: `customer_id`, `email`, `phone`, `city` | +| `orders`, `order_items` | Заказы | Заказы и позиции в них | +| `products` | Склад | Товары: `product_id`, `name` | +| `prices` | Склад | История цен: `product_id`, `valid_from`, `valid_to`, `price` | +| `promos` | Маркетинг | Акции: `promo_id`, `code` | + +⚠️ Обратите внимание: +- `customer_id = 101` в одном месяце — `a@ex.com`, в другом — `b@ex.com`; +- цена на товар `9001` (Phone) в январе — 100 ₽, в феврале — 110 ₽; +- `order_items` содержит `price_at_sale` — *цену в момент покупки*, а не текущую. + +Это уже **намёк**: чтобы посчитать выручку 2023 года, нам нужна не текущая цена, а *та, что была в день заказа*. + +(ER-диаграмма и DDL-примеры — в конце статьи, в разделе «Для практики».) + +--- + +## **3. Зачем делить DWH на слои?** + +Представьте, что вы строите дом. Вы же не будете сразу вбивать гвозди в стены — сначала: +1. Привезли стройматериалы (песок, доски, кирпич) — **сырьё**; +2. Очистили, просеяли, нарезали — **обработка**; +3. Собрали каркас, провели коммуникации — **интеграция**; +4. Сделали отделку под конкретную квартиру — **готовое решение**. + +В DWH — то же самое. Каждый слой отвечает за *одну задачу*: + +```mermaid +flowchart TD + subgraph Sources["Источники"] + A["CRM"] + B["Заказы"] + C["Склад"] + end + + subgraph STG["STG — «Сырьё»"] + D["Таблицы-дубликаты
в формате источника"] + end + + subgraph ODS["ODS — «Очистка»"] + E["Типы:
даты → DATE,
числа → INT/DECIMAL
Валидация: email, phone"] + end + + subgraph DDS["DDS — «Интеграция»"] + F["Общие сущности:
клиент, товар, дата
История (SCD),
суррогатные ключи"] + end + + subgraph DM["DM — «Готовые решения»"] + G["Витрина продаж: дата, товар, клиент, сумма
+ агрегаты (выручка/день)"] + end + + A --> STG + B --> STG + C --> STG + + STG --> ODS + ODS --> DDS + DDS --> DM + DM --> BI["BI-системы
(Power BI, Tableau,
Metabase)"] +``` + +👉 **Почему так лучше, чем «одна большая таблица»?** +1. **Управляемость**: если в `customers` пришёл битый `email` — ошибка локализована в STG/ODS, DDS не пострадает. +2. **Прозрачность**: можно посмотреть: «а как выглядел исходник?», «а как мы его почистили?». +3. **Производительность**: в DDS и DM — только то, что нужно для анализа. Никаких `JSON`-полей, `TEXT` без причины. + +--- + +## **4. Путешествие данных: от STG до DM** + +Давайте проследим, как превращается строка заказа. + +### **STG (Staging / «Бронза»)** — «как пришло» + +- Таблицы: `stg.orders_raw`, `stg.customers_raw`; +- Структура — *точно как в источнике* (может быть `VARCHAR` даже у дат); +- Добавлены технические поля: + - `_ingest_id` — идентификатор загрузки; + - `_load_ts` — время получения данных; +- Главное правило: **неизменяемость**. Если пришла новая порция — либо добавляем новые строки, либо *полностью перезагружаем* слой (идемпотентность). +- Дедупликация: если два раза пришёл один и тот же заказ — оставляем один (по `order_id + _load_ts`). + +> 💡 *Пример:* `stg.orders_raw` содержит `"2024-01-10"` как строку — это нормально. Главное — не потерять оригинал. + +--- + +### **ODS (Operational Data Store / «Серебро»)** — «почистили, но не трогали смысл» + +- Таблицы: `ods.orders`, `ods.customers`; +- Здесь: + - привели `order_date` к типу `DATE`; + - убрали заказы без клиента (`customer_id IS NULL` → ошибка или флаг); + - привели телефоны к формату `79991112233`; + - проверили email на валидность (регуляркой или простой проверкой). +- **Но!** Не объединяем клиента из CRM и клиента из заказов — это будет позже. +- Пока — никакой бизнес-логики. Только *техническая* очистка. + +> 🎯 Цель ODS — дать «надёжную платформу» для следующего слоя. Как сухое, чистое бревно перед сборкой дома. + +--- + +### **DDS (Data Delivery Store / «Ядро», «Золото»)** — «интеграция + история» + +Здесь рождается *единая бизнес-модель*. +Появляются понятия: **измерения**, **факты**, **суррогатные ключи**, **SCD**. + +Например: + +| Таблица | Назначение | +|---------|------------| +| `dds.dim_customer` | Измерение «Клиент» с историей (SCD Type 2) | +| `dds.dim_product` | Измерение «Товар» | +| `dds.dim_date` | Готовый календарь на 10 лет вперёд (день/неделя/месяц/квартал) | +| `dds.fact_sales` | Факт «Продажа» — строка заказа с суммой и количеством | + +💡 **Суррогатный ключ (Surrogate Key, SK)** — это `BIGINT`, который мы генерируем сами (например, `customer_sk = 1001`). +**Бизнес-ключ (Business Key, BK)** — это `customer_id = 101` из источника. +Мы храним и то, и другое — чтобы можно было и джойнить, и понимать, откуда строка. + +> ✅ Почему не использовать `customer_id` напрямую? +> — Потому что в одном источнике `customer_id` — целое число, в другом — строка `CUST-101`. +> — Потому что ID могут повторяться (например, в тестовой и продовой базах). +> — Потому что нам нужна *связь* с историей: у клиента с BK = `101` может быть 3 версии в `dim_customer`. + +--- + +### **DM (Data Mart / «Витрины»)** — «готово к употреблению» + +Здесь — таблицы и представления для конкретных задач: +- `dm.mart_daily_sales` — ежедневные продажи по товарам и сегментам; +- `dm.mart_customer_360` — полный портрет клиента: сколько потратил, когда заходил, какие товары любит. + +Они построены по модели **Звезда (Star Schema)** — потому что BI-инструментам так удобнее всего. + +--- + +## **5. Базовые понятия: факты, измерения, SCD** + +Представьте отчёт: +> *«10 января 2024 года клиент из Москвы (сегмент Premium) купил Phone за 100 ₽»*. + +В DWH это разложится на: +- **Факт (Fact)** — событие, которое можно измерить: *покупка*. + Хранится в `fact_sales`: `quantity = 1`, `amount = 100`. +- **Измерения (Dimensions)** — *контекст* факта: + - `dim_date` → 10 января 2024; + - `dim_customer` → Москва, Premium; + - `dim_product` → Phone. + +```mermaid +erDiagram + dim_date ||--o{ fact_sales : "дата" + dim_customer ||--o{ fact_sales : "клиент" + dim_product ||--o{ fact_sales : "товар" + + dim_customer { + bigint customer_sk PK "суррогатный ключ" + varchar customer_bk "бизнес-ключ, напр. '101'" + varchar customer_name + varchar email + varchar city + date valid_from + date valid_to + boolean is_current + } + + fact_sales { + bigint sale_id PK + bigint customer_sk FK + bigint product_sk FK + int date_key FK "ссылка на dim_date.date_key" + int quantity + decimal amount + } +``` + +### **SCD Type 2 — как хранить историю** + +Клиент №101: +- с 1 янв по 15 мая — `email = a@ex.com`, `city = Москва`; +- с 16 мая — `email = b@ex.com`, `city = Москва`; +- с 1 окт — `email = b@ex.com`, `city = Санкт-Петербург`. + +В `dim_customer` это будет **три строки**: + +| customer_sk | customer_bk | email | city | valid_from | valid_to | is_current | +|-------------|-------------|-------|------|------------|----------|------------| +| 1001 | 101 | a@ex.com | Москва | 2023-01-01 | 2023-05-15 | false | +| 1002 | 101 | b@ex.com | Москва | 2023-05-16 | 2023-09-30 | false | +| 1003 | 101 | b@ex.com | СПб | 2023-10-01 | 9999-12-31 | true | + +Когда мы считаем продажи за **12 января** — джойним `fact_sales` к той строке `dim_customer`, где: +```sql +fact_sales.order_date BETWEEN dim_customer.valid_from AND dim_customer.valid_to +``` +и получаем актуальный на тот день email и город. + +> 🔍 Подробнее про SCD — в отдельной статье [SQC](SCD.md) (сравнение Type 1/2/3, паттерны обновления). + +--- + +## **6. Модели данных для слоя DDS: 4 подхода — и когда какой выбрать** + +В DDS мы можем хранить данные по-разному. Это не «правильно/неправильно», а **выбор под задачу**. + +### **1. 3NF (третья нормальная форма)** +*Источник: Билл Инмон (Bill Inmon)* + +✅ **Плюсы**: +- Максимальная **целостность** — дубликатов нет (все атрибуты — там, где должны быть); +- Легко **расширять** — добавили новый источник? Расширили связанный справочник. + +❌ **Минусы**: +- Запросы сложные: много JOIN’ов даже для простого отчёта; +- Тяжело новичкам: «а где город клиента?» → нужно пройти `customer → address → city`. + +📌 **Когда выбирать**: +→ Корпоративные DWH, где важна *единая терминология* и *долгосрочная поддержка*. +→ Когда источников — десятки, и нужно гарантировать консистентность. + +--- + +### **2. Звезда (Star Schema)** +*Источник: Ральф Кимболл (Ralph Kimball)* + +✅ **Плюсы**: +- **Простота**: факт + несколько «плоских» измерений; +- **Скорость**: BI-системы любят звезду — запросы пишутся за 5 минут; +- **Понятно бизнесу**: «продажи по товарам и клиентам» — это ровно то, что в таблицах. + +❌ **Минусы**: +- Дублирование: город будет повторяться в каждой строке клиента; +- Изменение структуры измерения — дорого (перестроить всю витрину). + +📌 **Когда выбирать**: +→ Витрины (DM), а не ядро (DDS); +→ Начинающим командам и MVP; +→ Когда отчёты — главная цель. + +--- + +### **3. Data Vault 2.0** +*Источник: Дэн Линстедт (Dan Linstedt)* + +Модель основана на трёх типах таблиц: + +| Тип | Назначение | Пример | +|-----|------------|--------| +| **Hub (Хаб)** | Хранит бизнес-ключи (BK) | `hub_customer`: `customer_id`, `load_dttm` | +| **Link (Связь)** | Фиксирует отношения | `link_order_customer`: `order_id + customer_id` | +| **Satellite (Сателлит)** | Хранит атрибуты + историю | `sat_customer_info`: имя, email, дата начала/окончания | + +```mermaid +erDiagram + hub_customer ||--o{ sat_customer_info : "хаб → сателлит" + hub_order ||--o{ sat_order_details : "хаб → сателлит" + hub_customer ||--o{ link_order_customer : "участвует в" + hub_order ||--o{ link_order_customer : "создан клиентом" +``` + +✅ **Плюсы**: +- **История «из коробки»** — каждое изменение — новая строка в сателлите; +- **Масштабируемость**: легко подключить новый источник — добавили хаб/линк/сателлит; +- **Аудит**: откуда пришёл каждый факт — видно по `_load_dttm`. + +❌ **Минусы**: +- Сложно читать (20 таблиц вместо 3); +- Нужно писать сложные запросы (или использовать автоматическую генерацию витрин). + +📌 **Когда выбирать**: +→ Большие проекты с 10+ источниками; +→ Когда критична **трассировка** и **соответствие регуляторным требованиям** (например, финтех). + +> 📌 *DV 1.0* не поддерживал SCD Type 2 «из коробки» — в DV 2.0 это решено. + +--- + +### **4. Anchor Modeling (анкерное моделирование)** +*Источник: Ларс Рёне (Lars Rönnbäck)* + +Ещё более атомарный подход: +- **Anchor** — сущность (клиент, товар); +- **Attribute** — атрибут (email, имя); +- **Tie** — связь (как Link в DV); +- Все таблицы — 2–3 столбца. + +✅ **Плюсы**: +- **Максимальная гибкость**: поменяли модель — не трогали старые таблицы; +- **Бесконечная эволюция**: можно добавлять атрибуты «задним числом». + +❌ **Минусы**: +- Очень сложные запросы (JOIN’ов — десятки); +- Почти не используется «в чистом виде» — чаще как концепция. + +📌 **Когда выбирать**: +→ Экспериментальные проекты; +→ Когда схема данных *каждый месяц* радикально меняется. + +--- + +### **Сравнение моделей — наглядно** + +```mermaid +quadrantChart + title Где какая модель? (интуитивно) + x-axis "Низкая сложность → Высокая сло́жность" + y-axis "Низкая гибкость → Высокая гибкость" + "Звезда": [0.2, 0.3] + "3NF": [0.6, 0.5] + "Data Vault": [0.8, 0.8] + "Anchor": [0.95, 0.95] +``` + +> 🎯 **Вывод**: нет «лучшей» модели. Есть **подходящая под контекст**. +> — Для обучения — **Звезда** (просто, наглядно). +> — Для корпоративного DWH — **3NF + Звезда на выходе**. +> — Для масштабируемой интеграции — **Data Vault**. + +--- + +## **7. Практикум: как собрать первую витрину** + +Покажем на примере `mart_daily_sales` — таблицу, которую можно сразу подключить к Power BI. + +### **Этапы сборки** + +1. Из STG → ODS: + - `stg.orders_raw` → `ods.orders` (привели `order_date` к `DATE`); +2. Из ODS → DDS: + - `ods.customers` → `dds.dim_customer` (SCD Type 2); + - `ods.products` → `dds.dim_product`; + - `ods.orders` + `ods.order_items` → `dds.fact_sales`; +3. Из DDS → DM: + - `fact_sales` + `dim_*` → `mart_daily_sales`. + +```mermaid +flowchart LR + STG[stg.orders_raw] --> ODS[ods.orders] + ODS --> DDS[dds.fact_sales] + dds.dim_customer --> DDS + dds.dim_product --> DDS + dds.dim_date --> DDS + DDS --> DM[mart_daily_sales] + DM --> BI[Power BI] +``` + +### **Пример SQL-запроса для витрины** + +```sql +-- mart_daily_sales: ежедневные продажи с сегментацией +CREATE MATERIALIZED VIEW dm.mart_daily_sales AS +SELECT + d.date_actual AS order_date, + p.product_name, + c.customer_segment, -- например: 'Premium', 'Basic' + SUM(f.quantity) AS total_qty, + SUM(f.amount) AS total_revenue +FROM dds.fact_sales f +JOIN dds.dim_date d + ON f.date_key = d.date_key +JOIN dds.dim_product p + ON f.product_sk = p.product_sk +JOIN dds.dim_customer c + ON f.customer_sk = c.customer_sk + AND f.order_date BETWEEN c.valid_from AND c.valid_to -- SCD! +WHERE c.is_current = true -- или не фильтровать — тогда будет история +GROUP BY d.date_actual, p.product_name, c.customer_segment; +``` + +> 💡 **Материализованное представление (MATERIALIZED VIEW)** — это «кэш» результата. Обновляется по расписанию (например, ночью). + +--- + +## **8. Как выбрать модель? Дерево решений** + +Вот простой алгоритм: + +``` +Нужна ли история изменений? ── Нет → Звезда (просто и быстро) + │ + Да + │ +Много ли источников (>5)? ── Нет → 3NF (надёжно, понятно) + │ + Да + │ +Нужна ли аудиторская трассировка? ── Нет → 3NF + SCD + │ + Да → Data Vault +``` + +> 🛑 Не делайте «гибрид» без причины: +> — Звезда в DDS — плохо (потеряете гибкость); +> — DV в DM — плохо (BI не потянет 50 JOIN’ов). + +--- + +## **9. Эксплуатация: качество данных — это не «опция»** + +Самая красивая архитектура бессмысленна, если в `mart_daily_sales` — нули. +Поэтому в каждом слое — **контроль качества (DQ, Data Quality)**. + +```mermaid +graph TB + A[Данные поступили] --> B{Проверка качества} + + B --> C["Уникальность: order_id — уникален?"] + B --> D["Полнота: email не NULL?"] + B --> E["Валидность: order_date — дата?"] + B --> F["Свежесть: данные за сегодня?"] + + C --> G{OK?} + D --> G + E --> G + F --> G + + G -->|Да| H[Загрузить в следующий слой] + G -->|Нет| I[Оповещение + остановка пайплайна] +``` + +Примеры проверок (на SQL): + +```sql +-- Проверка уникальности order_id в ODS +SELECT order_id, COUNT(*) +FROM ods.orders +GROUP BY order_id +HAVING COUNT(*) > 1; + +-- Проверка свежести: есть ли данные за вчера? +SELECT 'OK' WHERE EXISTS ( + SELECT 1 FROM ods.orders + WHERE order_date = CURRENT_DATE - INTERVAL '1 day' +); +``` + +> 🔔 **Совет**: делайте DQ-тесты частью CI/CD — как unit-тесты в коде. + +--- + +## **10. Заключение: главное — понимать «почему»** + +Хранилище данных — это не про «крутые технологии», а про **мышление**: + +- **Слои (STG→ODS→DDS→DM)** — это про *разделение ответственности*. + Не смешивайте сырые данные и аналитические — иначе не найдёте, где ошибка. + +- **Факты и измерения** — это про *структуру мышления*. + События (факты) и контекст (измерения) — две стороны одного процесса. + +- **SCD Type 2** — это про *уважение к истории*. + Бизнес меняется — и данные должны это отражать. + +- **Модели (Star/3NF/DV)** — это про *выбор под задачу*. + Нет «серебряной пули» — есть компромиссы. + +> 🎁 **Финальный подарок**: +> Запомните **5 золотых правил DWH**: +> 1. Всегда храните BK (бизнес-ключ) — иначе потеряете связь с источником. +> 2. В DDS — только интегрированные, «чистые» сущности. +> 3. В DM — только то, что нужно для отчёта. +> 4. Проверяйте качество *на каждом слое*. +> 5. Собирайте витрины *итеративно*: MVP → доработка → новые метрики. + +--- + +## **Приложения** + +### 📚 Мини-глоссарий (RU / EN) + +| Термин | Пояснение | +|-------|-----------| +| **Слой (Layer)** | Логический уровень в DWH: STG/ODS/DDS/DM | +| **Витрина (Data Mart)** | Готовый набор таблиц для конкретной аналитики (например, финансы или маркетинг) | +| **Факт (Fact)** | Таблица событий или измерений: продажи, клики, звонки | +| **Измерение (Dimension)** | Справочник контекста: клиенты, товары, дата | +| **Суррогатный ключ (SK)** | Искусственный `BIGINT`, генерируемый в DWH | +| **Бизнес-ключ (BK)** | Естественный идентификатор из источника (`customer_id`, `order_number`) | +| **SCD (Slowly Changing Dimension)** | Подход к хранению истории атрибутов измерения | +| **CDC (Change Data Capture)** | Техника инкрементальной загрузки «только изменений» | +| **Conformed Dimension** | Измерение, единое для нескольких витрин (например, `dim_date`) | + +--- + +### 🧱 Синонимы слоёв в индустрии + +| Название | Синонимы | +|----------|----------| +| **STG** | Staging, Raw, Bronze, Landing Zone | +| **ODS** | Cleaned, Integrated, Silver | +| **DDS** | Core, Conformed, Golden Layer, Enterprise Data Model | +| **DM** | Data Mart, Semantic Layer, Gold, Analytics Layer | + +> ⚠️ Названия могут отличаться — смотрите на *содержание*, а не на ярлыки. + +--- + +### 🚫 Антипаттерны (чего избегать) + +| Антипаттерн | Почему плохо | +|-------------|--------------| +| **«Одна огромная история заказов»** | Запросы тормозят, нет истории атрибутов (клиент сменил email — и всё прошлое «перекрасилось») | +| **STG и ODS в одной таблице** | Невозможно понять: ошибка в источнике или при очистке? | +| **Факт с текстовыми атрибутами** (`customer_name` в `fact_sales`) | Дублирование, нарушение нормализации, «спрятанная» бизнес-логика | +| **SCD без BK** | История «отвязана» от бизнеса: удалили клиента — и вся его история исчезла | + +--- + +### 📖 Что почитать дальше? + +| Книга / Ресурс | Для кого | +|----------------|----------| +| **Кимболл, «Техника создания хранилищ данных»** | Начинающим: звезда, SCD, витрины | +| **Инмон, «Построение хранилищ данных»** | Для понимания 3NF и корпоративного подхода | +| **Linstedt & Olschimke, «Data Vault 2.0»** | Практическое руководство по DV | +| **[anchor-modeling.com](https://www.anchormodeling.com/)** | Официальный сайт Anchor Modeling | +| **Курс «Аналитика в Postgres» (Stepik / Postgres Pro)** | Практика SQL + DWH на реальных данных | + +--- + +## **ЧАСТЬ C. Мини-датасет (для практики)** + +Положите эти файлы в папку `data/` — и тренируйтесь: + +**`customers.csv`** +```csv +customer_id,email,phone,city +101,a@ex.com,700,Москва +101,b@ex.com,700,Москва +102,c@ex.com,701,СПб +``` + +**`orders.csv`** +```csv +order_id,order_date,customer_id +5001,2024-01-10,101 +5002,2024-02-05,102 +``` + +**`order_items.csv`** +```csv +order_item_id,order_id,product_id,qty,price_at_sale +1,5001,9001,2,100.00 +2,5001,9002,1,50.00 +3,5002,9001,1,100.00 +``` + +**`products.csv`** +```csv +product_id,name +9001,Phone +9002,Case +``` + +**`prices.csv`** +```csv +product_id,valid_from,valid_to,price +9001,2023-12-01,2024-01-31,100 +9001,2024-02-01,2999-12-31,110 +``` + +> 📂 Примеры DDL, SQL-загрузки, SCD-скрипты — в [GitHub-репозитории к статье](https://github.com/yourname/dwh-basics) (реальный репо — по вашему усмотрению). + +--- + +## **ЧАСТЬ D. DDL-скелеты (PostgreSQL)** + +```sql +-- DDS: измерение клиента (SCD Type 2) +CREATE TABLE dds.dim_customer ( + customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_bk VARCHAR(50) NOT NULL, -- напр. '101' + email VARCHAR(100), + phone VARCHAR(20), + city VARCHAR(50), + valid_from DATE NOT NULL, + valid_to DATE DEFAULT '9999-12-31', + is_current BOOLEAN NOT NULL DEFAULT TRUE +); + +-- DDS: факт продаж (гранулярность: строка заказа) +CREATE TABLE dds.fact_sales ( + sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_sk BIGINT NOT NULL REFERENCES dds.dim_customer(customer_sk), + product_sk BIGINT NOT NULL, + date_key INT NOT NULL, -- YYYYMMDD, ссылка на dim_date.date_key + quantity INT NOT NULL CHECK (quantity > 0), + amount DECIMAL(18,2) NOT NULL CHECK (amount >= 0) +); +``` + +> 💡 `date_key` — это `20240110`, а не `DATE`, чтобы не делать JOIN по диапазону в `fact → dim_date`. + +--- + +Если нужно — могу: +- добавить **интерактивные схемы** (например, кликабельные Mermaid → SVG); +- подготовить **полный SQL-скрипт загрузки** (STG→ODS→DDS); +- сделать **вариант статьи в формате Jupyter Notebook** (с исполняемыми ячейками). + +Готов дорабатывать под ваш стиль и аудиторию. \ No newline at end of file diff --git a/SCD.md b/dwh-modeling/SCD.md similarity index 100% rename from SCD.md rename to dwh-modeling/SCD.md diff --git a/dwh-modeling/data/customers.csv b/dwh-modeling/data/customers.csv new file mode 100644 index 0000000..0cd27b5 --- /dev/null +++ b/dwh-modeling/data/customers.csv @@ -0,0 +1,4 @@ +customer_id,email,phone,city +101,a@ex.com,700,Москва +101,b@ex.com,700,Москва +102,c@ex.com,701,СПб \ No newline at end of file diff --git a/dwh-modeling/data/order_items.csv b/dwh-modeling/data/order_items.csv new file mode 100644 index 0000000..a52fe15 --- /dev/null +++ b/dwh-modeling/data/order_items.csv @@ -0,0 +1,4 @@ +order_item_id,order_id,product_id,qty,price_at_sale +1,5001,9001,2,100.00 +2,5001,9002,1,50.00 +3,5002,9001,1,100.00 \ No newline at end of file diff --git a/dwh-modeling/data/orders.csv b/dwh-modeling/data/orders.csv new file mode 100644 index 0000000..c46442e --- /dev/null +++ b/dwh-modeling/data/orders.csv @@ -0,0 +1,3 @@ +order_id,order_date,customer_id +5001,2024-01-10,101 +5002,2024-02-05,102 \ No newline at end of file diff --git a/dwh-modeling/data/prices.csv b/dwh-modeling/data/prices.csv new file mode 100644 index 0000000..6f1c6ac --- /dev/null +++ b/dwh-modeling/data/prices.csv @@ -0,0 +1,4 @@ +product_id,valid_from,valid_to,price +9001,2023-12-01,2024-01-31,100.00 +9001,2024-02-01,2999-12-31,110.00 +9002,2023-01-01,2999-12-31,50.00 \ No newline at end of file diff --git a/dwh-modeling/data/products.csv b/dwh-modeling/data/products.csv new file mode 100644 index 0000000..3cc0d0c --- /dev/null +++ b/dwh-modeling/data/products.csv @@ -0,0 +1,3 @@ +product_id,name +9001,Phone +9002,Case \ No newline at end of file diff --git a/dwh-modeling/dimensions_sample.sql b/dwh-modeling/dimensions_sample.sql new file mode 100644 index 0000000..7bf01ab --- /dev/null +++ b/dwh-modeling/dimensions_sample.sql @@ -0,0 +1,255 @@ +-- =============================================== +-- 1. Создание схем +-- =============================================== +DROP SCHEMA IF EXISTS stg CASCADE; +DROP SCHEMA IF EXISTS ods CASCADE; +DROP SCHEMA IF EXISTS dds CASCADE; +CREATE SCHEMA stg; +CREATE SCHEMA ods; +CREATE SCHEMA dds; + +-- =============================================== +-- 2. STG: таблицы «как пришло» +-- =============================================== + +-- Сырые данные: строки, как из CSV/API +CREATE TABLE stg.customers_raw ( + customer_id TEXT, -- может быть '101' или 'CUST-101' + email TEXT, + phone TEXT, + city TEXT, + _load_ts TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE stg.orders_raw ( + order_id TEXT, + order_date TEXT, -- формат: '2024-01-10' + customer_id TEXT +); + +CREATE TABLE stg.order_items_raw ( + order_item_id TEXT, + order_id TEXT, + product_id TEXT, + qty TEXT, -- может быть '2', 'NULL' + price_at_sale TEXT -- может быть '100.00' +); + +CREATE TABLE stg.products_raw ( + product_id TEXT, + name TEXT +); + +-- Загрузка тестовых данных (вместо COPY FROM CSV) +INSERT INTO stg.customers_raw (customer_id, email, phone, city) VALUES +('101', 'a@ex.com', '700', 'Москва'), +('101', 'b@ex.com', '700', 'Москва'), -- изменение email +('102', 'c@ex.com', '701', 'СПб'); + +INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES +('5001', '2024-01-10', '101'), +('5002', '2024-02-05', '102'); + +INSERT INTO stg.order_items_raw (order_item_id, order_id, product_id, qty, price_at_sale) VALUES +('1', '5001', '9001', '2', '100.00'), +('2', '5001', '9002', '1', '50.00'), +('3', '5002', '9001', '1', '100.00'); + +INSERT INTO stg.products_raw (product_id, name) VALUES +('9001', 'Phone'), +('9002', 'Case'); + +-- =============================================== +-- 3. ODS: очистка и типизация +-- =============================================== + +-- Очищенные клиенты +CREATE TABLE ods.customers AS +SELECT + customer_id::INT AS customer_id, -- приведение к INT + NULLIF(TRIM(email), '') AS email, -- пустые → NULL + NULLIF(TRIM(phone), '') AS phone, + NULLIF(TRIM(city), '') AS city +FROM stg.customers_raw +WHERE customer_id ~ '^\d+$'; -- валидация: только цифры + +-- Очищенные заказы +CREATE TABLE ods.orders AS +SELECT + order_id::INT, + TO_DATE(order_date, 'YYYY-MM-DD') AS order_date, + customer_id::INT +FROM stg.orders_raw +WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$'; + +-- Очищенные позиции заказа +CREATE TABLE ods.order_items AS +SELECT + order_item_id::INT, + order_id::INT, + product_id::INT, + NULLIF(qty, '')::INT AS qty, + NULLIF(price_at_sale, '')::NUMERIC(10,2) AS price_at_sale +FROM stg.order_items_raw +WHERE qty ~ '^\d+$' AND price_at_sale ~ '^\d+(\.\d+)?$'; + +-- Очищенные товары +CREATE TABLE ods.products AS +SELECT + product_id::INT, + TRIM(name) AS name +FROM stg.products_raw +WHERE product_id ~ '^\d+$'; + +-- Добавим первичные ключи (для ускорения JOIN и проверки) +ALTER TABLE ods.customers ADD PRIMARY KEY (customer_id); +ALTER TABLE ods.orders ADD PRIMARY KEY (order_id); +ALTER TABLE ods.order_items ADD PRIMARY KEY (order_item_id); +ALTER TABLE ods.products ADD PRIMARY KEY (product_id); + +-- =============================================== +-- 4. DDS: интеграция + SCD Type 2 +-- =============================================== + +-- 4.1 dim_date — справочник дат (на 5 лет: 2023–2027) +CREATE TABLE dds.dim_date AS +WITH RECURSIVE dates AS ( + SELECT DATE '2023-01-01' AS d + UNION ALL + SELECT d + INTERVAL '1 day' + FROM dates + WHERE d + INTERVAL '1 day' <= DATE '2027-12-31' +) +SELECT + CAST(TO_CHAR(d, 'YYYYMMDD') AS INT) AS date_key, + d AS date_actual, + EXTRACT(YEAR FROM d) AS year, + EXTRACT(QUARTER FROM d) AS quarter, + EXTRACT(MONTH FROM d) AS month, + EXTRACT(DAY FROM d) AS day, + TO_CHAR(d, 'Day') AS weekday_name, + EXTRACT(DOW FROM d) AS weekday_num, -- 0 = воскресенье + d BETWEEN + DATE_TRUNC('month', d) + AND DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day' + AND EXTRACT(DAY FROM d) <= 7 + AS is_first_week +FROM dates; + +ALTER TABLE dds.dim_date ADD PRIMARY KEY (date_key); + +-- 4.2 dim_product — измерение «Товар» (без истории — атрибуты стабильны) +CREATE TABLE dds.dim_product ( + product_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + product_bk INT NOT NULL, + product_name VARCHAR(100) NOT NULL +); + +INSERT INTO dds.dim_product (product_bk, product_name) +SELECT product_id, name +FROM ods.products; + +-- 4.3 dim_customer — SCD Type 2 (с историей) +CREATE TABLE dds.dim_customer ( + customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_bk INT NOT NULL, + email VARCHAR(100), + phone VARCHAR(20), + city VARCHAR(50), + valid_from DATE NOT NULL, + valid_to DATE NOT NULL DEFAULT '9999-12-31', + is_current BOOLEAN NOT NULL DEFAULT TRUE +); + +-- Загрузка первой версии (SCD Type 2 — простой алгоритм) +-- Группируем по customer_bk и сортируем по времени (по _load_ts из STG) +-- Для учебного примера имитируем хронологию через порядок строк +WITH ranked AS ( + SELECT + customer_id AS customer_bk, + email, + phone, + city, + ROW_NUMBER() OVER ( + PARTITION BY customer_id + ORDER BY _load_ts, email -- хронология изменений + ) AS rn + FROM stg.customers_raw + WHERE customer_id ~ '^\d+$' +), +changes AS ( + SELECT + customer_bk, + email, + phone, + city, + -- начальная дата — день первого появления в источнике + '2023-01-01'::DATE + (rn - 1) * INTERVAL '1 day' AS eff_from + FROM ranked +), +final AS ( + SELECT + customer_bk, + email, + phone, + city, + eff_from::DATE AS valid_from, + COALESCE( + LEAD(eff_from) OVER (PARTITION BY customer_bk ORDER BY eff_from) - INTERVAL '1 day', + '9999-12-31'::DATE + ) AS valid_to, + CASE WHEN + LEAD(eff_from) OVER (PARTITION BY customer_bk ORDER BY eff_from) IS NULL + THEN TRUE ELSE FALSE END AS is_current + FROM changes +) +INSERT INTO dds.dim_customer (customer_bk, email, phone, city, valid_from, valid_to, is_current) +SELECT customer_bk, email, phone, city, valid_from, valid_to, is_current +FROM final; + +-- 4.4 fact_sales — факт продаж +CREATE TABLE dds.fact_sales ( + sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_sk BIGINT NOT NULL, -- ссылка на DDS-измерение + product_sk BIGINT NOT NULL, + date_key INT NOT NULL, -- YYYYMMDD + quantity INT NOT NULL CHECK (quantity > 0), + amount NUMERIC(18,2) NOT NULL CHECK (amount >= 0) +); + +-- Заполнение fact_sales с учётом истории клиента (SCD!) +INSERT INTO dds.fact_sales (customer_sk, product_sk, date_key, quantity, amount) +SELECT + dc.customer_sk, + dp.product_sk, + CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT) AS date_key, + oi.qty, + oi.price_at_sale * oi.qty AS amount +FROM ods.orders o +JOIN ods.order_items oi ON o.order_id = oi.order_id +JOIN ods.products p ON oi.product_id = p.product_id +JOIN dds.dim_product dp ON p.product_id = dp.product_bk +JOIN dds.dim_customer dc + ON o.customer_id = dc.customer_bk + AND o.order_date BETWEEN dc.valid_from AND dc.valid_to; -- ← ключевая строка SCD! + +-- Добавим внешние ключи (опционально, для целостности) +ALTER TABLE dds.fact_sales + ADD FOREIGN KEY (customer_sk) REFERENCES dds.dim_customer(customer_sk), + ADD FOREIGN KEY (product_sk) REFERENCES dds.dim_product(product_sk), + ADD FOREIGN KEY (date_key) REFERENCES dds.dim_date(date_key); + +-- =============================================== +-- 5. Проверка: посчитаем выручку по клиентам +-- =============================================== +SELECT + dc.customer_bk, + dc.email, + dc.city, + SUM(f.amount) AS total_revenue +FROM dds.fact_sales f +JOIN dds.dim_customer dc + ON f.customer_sk = dc.customer_sk + AND dc.is_current -- только актуальная версия +GROUP BY dc.customer_bk, dc.email, dc.city +ORDER BY total_revenue DESC; diff --git a/dwh-modeling-basics.md b/dwh-modeling/dwh-modeling-basics.md similarity index 100% rename from dwh-modeling-basics.md rename to dwh-modeling/dwh-modeling-basics.md diff --git a/dwh-modeling-ds.md b/dwh-modeling/dwh-modeling-ds.md similarity index 100% rename from dwh-modeling-ds.md rename to dwh-modeling/dwh-modeling-ds.md diff --git a/dwh-modeling/dwh-modeling-plan3.md b/dwh-modeling/dwh-modeling-plan3.md new file mode 100644 index 0000000..eb2a4f6 --- /dev/null +++ b/dwh-modeling/dwh-modeling-plan3.md @@ -0,0 +1,570 @@ +# Модели данных и слои DWH (для менти) + +**Цель и аудитория:** эта статья для начинающих аналитиков/инженеров данных, которые уже умеют базовый SQL (SELECT/JOIN/GROUP BY/WHERE) и переходят к моделированию данных и архитектуре хранилища (Data Warehouse, DWH). Здесь даём общее представление: **зачем слои**, **что такое модели**, **чем они отличаются**, где обычно живут **факты/измерения/SCD**. **Выбор модели** в продакшен за вас делать не будем — на этом этапе важно понять, *зачем разные модели существуют* и *какие задачи они решают*. + +--- + +## ЧАСТЬ A. Исходный черновик статьи (переделан в чистовик с лёгкой правкой опечаток) + +Ниже — аккуратно отредактированная версия вашего плана с диаграммами Mermaid. Сохранили исходную структуру и формулировки, поправили опечатки и выровняли стиль именования. + +--- + +# **Структура статьи про хранилище данных** + +1. Введение. Проблема аналитики в OLTP +2. Учебный пример. Интернет-магазин +3. Архитектура. Зачем слои? +4. Путешествие данных. STG → ODS → DDS → DM +5. Базовые понятия. Факты, Измерения, SCD +6. Модели данных. 3NF, Data Vault, Звезда +7. Практикум. Собираем витрину +8. Зачем разные модели (демо-«дерево решений») +9. Эксплуатация. Качество и эволюция +10. Заключение + +> Примечание про Mermaid: диаграммы `mindmap` и `quadrantChart` поддерживаются не всеми рендерами. Если планируете публикацию на площадке со строгим Markdown (например, Habr), оставьте текстовую альтернативу. + +--- + +## **1. Введение: аналитика — это не оперативный учёт** + +**OLTP (оперативный учёт)**: CRM, заказы, склад. +**DWH (аналитическое хранилище)**: единая модель для анализа. + +Проблема: в OLTP для исторической аналитики — сложные JOIN, тяжёлые агрегации, нестабильная производительность. + +**Особенности DWH**: + +* Слоистая архитектура +* Оптимизированные под аналитику модели + +**Ключевые тезисы**: + +* OLTP vs OLAP: транзакции против анализа +* Почему «одна большая таблица» не работает на истории +* 3 преимущества слоёв: управляемость, производительность, прозрачность + +--- + +## **2. Учебный пример: интернет-магазин** + +ER-диаграмма сущностей источника (упрощённо): + +```mermaid +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ ORDER_ITEM : contains + PRODUCT ||--o{ ORDER_ITEM : referenced + PRODUCT ||--o{ PRICE : has + PROMO ||--o{ ORDER_ITEM : applied + CUSTOMER { + int customer_id PK + string email + string phone + } + PRODUCT { + int product_id PK + string name + } + ORDER { + int order_id PK + date order_date + int customer_id FK + } + ORDER_ITEM { + int order_item_id PK + int order_id FK + int product_id FK + int qty + numeric price_at_sale + } + PRICE { + int product_id FK + date valid_from + date valid_to + numeric price + } + PROMO { + int promo_id PK + string code + } +``` + +--- + +## **3. Архитектура хранилища: зачем делить на слои?** + +```mermaid +flowchart TD + subgraph Sources[Источники OLTP] + A[CRM] + B[Заказы] + C[Склад] + end + + A --> STG + B --> STG + C --> STG + + subgraph STG[STG (Bronze) — сырые данные] + D[Таблицы-клоны \n неизменяемые/переигрузка] + end + + STG --> ODS + + subgraph ODS[ODS (Silver) — очищенные] + E[Типизация, валидация, \n стандартизация кодов] + end + + ODS --> DDS + + subgraph DDS[DDS — интегрированная модель] + F[Бизнес-сущности, SK/BK, SCD] + end + + DDS --> DM + + subgraph DM[DM (Gold) — витрины] + G[Звезда/Снежинка, агрегаты] + end + + DM --> BI[BI/Отчёты] +``` + +--- + +## **4. Путешествие данных по слоям** + +```mermaid +flowchart LR + subgraph STG[STG — сырьё] + A[raw_orders.json] + B[raw_customers.csv] + C[Формат как в источнике] + end + + subgraph ODS[ODS — очистка] + D[stg_orders] + E[stg_customers] + F[Типизация, валидация] + end + + subgraph DDS[DDS — интеграция] + G[dim_customer — SCD2] + H[fact_orders — с суррогатными ключами] + end + + subgraph DM[DM — готовые решения] + I[mart_sales — агрегаты] + J[mart_customer_360] + end + + STG --> ODS + ODS --> DDS + DDS --> DM +``` + +### 4.1 STG (Staging/Bronze) + +Коротко: «как пришло». Идемпотентность, дедупликация, неизменяемость/переигрузка. + +### 4.2 ODS (Operational Data Store/Silver) + +Коротко: чистка и выравнивание типов, базовая унификация кодов, ещё без тяжёлой бизнес-логики. + +### 4.3 DDS (Integrated/Conformed) + +Коротко: интеграция источников, общие справочники, SK/BK, SCD. Здесь живут модели данных (3NF/DV/Anchor/Star). + +### 4.4 DM (Data Marts/Gold) + +Коротко: модели под задачи BI (звезда/снежинка). Агрегаты, материализации. + +--- + +## **5. Базовые понятия: факты, измерения, ключи** + +```mermaid +erDiagram + dim_date ||--o{ fact_sales : "дата" + dim_customer ||--o{ fact_sales : "клиент" + dim_product ||--o{ fact_sales : "товар" + + dim_customer { + bigint customer_sk PK + varchar customer_bk + varchar customer_name + varchar email + date valid_from + date valid_to + boolean is_current + } + + fact_sales { + bigint sale_id PK + bigint customer_sk FK + bigint product_sk FK + int date_sk FK + int quantity + decimal amount + } +``` + +**SCD Type 2 — визуализация истории:** + +```mermaid +gantt + title SCD Type 2: История изменений клиента (ID = 123) + dateFormat YYYY-MM-DD + axisFormat %Y-%m + + section Москва, premium@email.com + Версия 1 :active, 2023-01-01, 2023-05-15 + + section Москва, new_premium@email.com + Версия 2 :active, 2023-05-16, 2023-09-30 + + section Санкт-Петербург, new_premium@email.com + Версия 3 :active, 2023-10-01, 2024-12-31 +``` + +--- + +## **6. Модели данных для DDS** + +### 3NF (третья нормальная форма) + +Классическая нормализованная модель, сильная целостность и интеграция, но SQL для аналитики сложнее. + +### Снежинка (Snowflake) + +Нормализованные измерения поверх звезды — компромисс между читаемостью и дублированием. + +### Звезда (Star Schema) + +Факт + денормализованные измерения — просто и быстро для BI/SQL. + +### **Data Vault 2.0** + +```mermaid +erDiagram + HUB_CUSTOMER ||--o{ SAT_CUSTOMER : has_history + HUB_ORDER ||--o{ SAT_ORDER : has_history + HUB_PRODUCT ||--o{ SAT_PRODUCT : has_history + + HUB_CUSTOMER ||--o{ LNK_ORDER_CUSTOMER : participates + HUB_ORDER ||--o{ LNK_ORDER_CUSTOMER : includes + + HUB_ORDER ||--o{ LNK_ORDER_PRODUCT : contains + HUB_PRODUCT ||--o{ LNK_ORDER_PRODUCT : involved + + HUB_CUSTOMER { + string customer_hk PK + string customer_bk + datetime load_dts + string src + } + + LNK_ORDER_PRODUCT { + string order_product_hk PK + string order_hk FK + string product_hk FK + datetime load_dts + string src + } + + SAT_CUSTOMER { + string customer_hk PK,FK + datetime load_dts PK + string email + string phone + string city + hashdiff hash + } +``` + +Коротко: хабы (BK), линки (связи), сателлиты (история атрибутов). Этот раздел обзорный. + +### Anchor Modeling (анкерное моделирование) + +Атомарная декомпозиция сущностей и атрибутов, гибкая эволюция схемы; высокая гранулярность усложняет чтение. + +> **Сравнение (интуитивно):** Звезда — проще/быстрее для BI; 3NF — целостность и интеграция; DV/Anchor — масштабируемая интеграция из многих источников и «история по умолчанию», но сложнее читать и писать. + +--- + +## **7. Практикум: собираем витрину** + +```mermaid +flowchart TD + A[Источники] --> STG + STG[STG: сырые заказы, товары] --> ODS + ODS[ODS: очищенные данные] --> DDS + + subgraph DDS[DDS: интегрированная модель] + B[dim_customer (SCD2)] + C[dim_product] + D[dim_date] + E[fact_sales] + end + + DDS --> F{Сборка витрины} + + F --> G[mart_daily_sales] + F --> H[mart_customer_lifetime] + + G --> I[Дашборд продаж] + H --> J[Отчёт по клиентам] +``` + +**Пример SQL для витрины:** + +```sql +-- Витрина ежедневных продаж (пример) +CREATE TABLE mart_daily_sales AS +SELECT + d.date, + p.product_name, + c.customer_segment, + SUM(f.quantity) AS total_quantity, + SUM(f.amount) AS total_amount +FROM fact_sales f +JOIN dim_date d ON f.date_sk = d.date_sk +JOIN dim_product p ON f.product_sk = p.product_sk +JOIN dim_customer c ON f.customer_sk = c.customer_sk AND c.is_current = TRUE +GROUP BY d.date, p.product_name, c.customer_segment; +``` + +--- + +## **9. Эксплуатация: качество и эволюция** + +```mermaid +graph TB + A[Данные] --> B{Контроль качества} + B --> C[Проверка уникальности] + B --> D[Проверка полноты] + B --> E[Валидация форматов] + B --> F[Свежесть данных] + C --> G[✅ Успех] + D --> G + E --> G + F --> G + C --> H[❌ Ошибка] + D --> H + E --> H + F --> H + G --> I[Загрузка в слой] + H --> J[Оповещение и остановка] +``` + +--- + +## **10. Заключение: главное — понимать «почему»** + +Ключевые идеи: разделение на слои (STG → ODS → DDS → DM), разные модели для разных задач (3NF, Star, DV, Anchor), факты/измерения/SCD, итеративная сборка витрин под конкретные вопросы бизнеса. + +--- + +## ЧАСТЬ B. Наши предложения к статье (что усилено и что добавить) + +Ниже — блоки, которые можно встроить в соответствующие разделы или вынести в отдельные боксы/врезки. + +### 1) Рамочный ввод «что вы знаете / что узнаете / чего не будет» + +* **Уже умеете:** SQL-основы (SELECT/JOIN/GROUP BY), простые агрегаты. +* **Узнаете:** слои (STG/ODS/DDS/DM), модели (3NF/Star/DV/Anchor), SCD и суррогатные ключи. +* **Не рассматриваем здесь:** физический дизайн, производительность, партиционирование, распределённые кластеры — это отдельная тема. + +### 2) Мини-глоссарий RU/EN (по 1–2 строки) + +* **Слой (Layer)** — логический уровень в DWH: STG/ODS/DDS/DM. +* **Витрина (Data Mart)** — предметно-ориентированный набор таблиц/представлений для конкретной аналитики. +* **Факт (Fact)** — таблица событий/измерений величин (кол-во, сумма). +* **Измерение (Dimension)** — справочник контекста фактов (клиенты, товары, даты). +* **Суррогатный ключ (Surrogate Key, SK)** — искусственный технический ключ (int/bigint). +* **Бизнес-ключ (Business Key, BK)** — естественный ключ из источника (например, `customer_id`). +* **SCD (Slowly Changing Dimension)** — подход к хранению истории атрибутов измерения. +* **CDC (Change Data Capture)** — техника инкрементальной загрузки изменений. +* **Conformed Dimension** — «конформное» измерение, общее для нескольких витрин. + +### 3) Отображение синонимов слоёв + +* **STG (Bronze)** → **ODS (Silver)** → **DDS (Conformed/Core)** → **DM (Gold)**. + +### 4) «Правила слоя» — короткие чек-листы + +* **STG (Bronze):** как пришло; неизменяемо/переигрузка; идемпотентность; дедуп по `(BK, load_ts)`; только тех. обогащения (например, `_ingest_id`). +* **ODS (Silver):** типизация; базовая очистка и стандартные коды; **без** тяжёлой бизнес-логики; стабильные схемы/имена. +* **DDS (Conformed/Core):** интеграция источников; единая терминология; SK/BK; SCD; здесь живут модели (3NF/Star/DV/Anchor). +* **DM (Gold):** под конкретные вопросы BI/продукта; агрегаты/материализации; доступные метрики и измерения. + +### 5) Антипаттерны (короткий бокс) + +* «Одна огромная историческая таблица» → медленные запросы, нет истории атрибутов. +* «Смешали STG и ODS» → потеря трассировки ошибок и инцидентов качества. +* «Факт с текстовыми атрибутами без причин» → раздутая таблица и неявные бизнес-правила. +* «SCD без BK» → история «плывёт», не привязана к бизнес-идентификатору. + +### 6) SCD2: рецепт джойна «по интервалу» (шпаргалка) + +```sql +-- Привязка факта к актуальной версии измерения (Type 2) +SELECT f.*, d.customer_sk +FROM fact_sales f +JOIN dim_customer d + ON d.customer_bk = f.customer_bk -- совпадение BK + AND f.event_date >= d.valid_from + AND f.event_date < COALESCE(d.valid_to, DATE '2999-12-31'); +``` + +### 7) Мини-практикум (сквозной, короткий) + +1. Показать джойн факта к SCD2-измерению (по примеру выше). +2. Собрать `mart_daily_sales` (у вас уже есть SQL). +3. Пара проверок качества (см. ниже). + +### 8) Качество данных: минимальные метрики + однострочники + +* **Freshness (свежесть):** `MAX(event_time)` против «сейчас». +* **Completeness (полнота):** доля NULL по важным столбцам. +* **Consistency (согласованность):** факт без соответствия в измерении (нарушение покрытии ссылок). +* **Uniqueness (уникальность):** уникальность BK в справочнике. + +```sql +-- Примеры «однострочников» +-- Freshness +SELECT NOW() - MAX(event_time) AS lag FROM ods.order; + +-- Uniqueness BK +SELECT customer_bk, COUNT(*) +FROM dds.dim_customer +GROUP BY customer_bk +HAVING COUNT(*) > 1; + +-- Orphan facts (без покрытия измерения) +SELECT COUNT(*) +FROM dds.fact_sales f +LEFT JOIN dds.dim_customer d ON f.customer_sk = d.customer_sk +WHERE d.customer_sk IS NULL; +``` + +### 9) Нейминг-гайд (пример) + +* `stg.*` — как в источнике, добавочные тех. поля: `_ingest_id`, `_load_ts`. +* `ods.*` — очищенные «плоские» таблицы, стаб. схемы. +* `dds.dim_*`, `dds.fact_*` — интегрированная модель. +* `dm.mart_*` — витрины/представления. + +### 10) «Зачем разные модели» (без выбора) + +* **Star (звезда):** быстро писать отчёты, учим новичков на ней. +* **3NF:** лучшая консистентность/интеграция понятий, тяжелее для BI. +* **Data Vault:** масштабируемая интеграция множества источников + «история по умолчанию». +* **Anchor:** гибкая эволюция схемы, максимальная атомарность, цена — сложность. + +### 11) Что почитать дальше + +* **Kimball, The Data Warehouse Toolkit** — модели «звезды», SCD. +* **Inmon, Building the Data Warehouse** — корпоративный DWH и нормализация. +* **Linstedt & Olschimke, The Data Vault 2.0** — практический DV. +* **Anchor Modeling** — официальный сайт/документация по Anchor. + +--- + +## ЧАСТЬ C. Мини-датасет (для примеров в статье) + +> Опционально приложить к репозиторию/приложению статьи. + +**customers.csv** + +``` +customer_id,email,phone,city +101,a@ex.com,700,Москва +101,b@ex.com,700,Москва +102,c@ex.com,701,СПб +``` + +**orders.csv** + +``` +order_id,order_date,customer_id +5001,2024-01-10,101 +5002,2024-02-05,102 +``` + +**order_items.csv** + +``` +order_item_id,order_id,product_id,qty,price_at_sale +1,5001,9001,2,100.00 +2,5001,9002,1,50.00 +3,5002,9001,1,100.00 +``` + +**products.csv** + +``` +product_id,name +9001,Phone +9002,Case +``` + +**prices.csv** + +``` +product_id,valid_from,valid_to,price +9001,2023-12-01,2024-01-31,100 +9001,2024-02-01,2999-12-31,110 +``` + +--- + +## ЧАСТЬ D. DDL-скелеты (минимально) + +```sql +-- DDS: измерение клиента (Type 2) +CREATE TABLE dds.dim_customer ( + customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_bk VARCHAR NOT NULL, + email VARCHAR, + phone VARCHAR, + city VARCHAR, + valid_from DATE NOT NULL, + valid_to DATE, + is_current BOOLEAN NOT NULL DEFAULT TRUE +); + +-- DDS: факт продаж (гранулярность: строка заказа) +CREATE TABLE dds.fact_sales ( + sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_sk BIGINT NOT NULL, + product_sk BIGINT NOT NULL, + date_sk INT NOT NULL, + quantity INT NOT NULL, + amount DECIMAL(18,2) NOT NULL +); +``` + +--- + +## ЧАСТЬ E. Текстовые альтернативы для «капризных» диаграмм + +Если `mindmap`/`quadrantChart` не поддерживаются местом публикации, используйте пункты: + +* **Сводка:** + + * Архитектура: слои STG→ODS→DDS→DM, разделение ответственности, управляемость изменений. + * Модели: 3NF (целостность), Data Vault (масштабируемость), Звезда (производительность), Anchor (гибкая эволюция). + * Понятия: факты, измерения, SCD2, SK/BK. + * Практика: понимать бизнес-задачу, выбирать подход под контекст, строить итеративно. + +* **Интуитивное сравнение моделей (простыми словами):** + Звезда — «быстро стартануть BI»; 3NF — «собрать единый словарь сущностей»; DV/Anchor — «надёжно интегрировать десятки источников и хранить всю историю», но читать сложнее. + +--- + +## Итог + +Статья даёт общий словарь и картинку мира: что за слои в DWH, какие модели бывают и зачем они нужны. Задача менти — почувствовать интуицию различий, научиться видеть место фактов/измерений/SCD и уверенно собрать простую витрину из DDS. Дальше — углубляться по нужной модели в отдельном материале (звезда/DV/Anchor/3NF). diff --git a/dwh-modeling/sql/01_ddl.sql b/dwh-modeling/sql/01_ddl.sql new file mode 100644 index 0000000..4c88eb8 --- /dev/null +++ b/dwh-modeling/sql/01_ddl.sql @@ -0,0 +1,125 @@ +-- =============================================== +-- DDL-скрипт: определение структуры хранилища +-- Запускается ОДИН РАЗ при инициализации БД +-- или при изменении схемы (миграции) +-- =============================================== + +-- 1. Схемы +DROP SCHEMA IF EXISTS stg CASCADE; +DROP SCHEMA IF EXISTS ods CASCADE; +DROP SCHEMA IF EXISTS dds CASCADE; + +CREATE SCHEMA stg; +CREATE SCHEMA ods; +CREATE SCHEMA dds; + +-- 2. STG: сырые данные (как пришли) +CREATE TABLE stg.customers_raw ( + customer_id TEXT, + email TEXT, + phone TEXT, + city TEXT, + _load_ts TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE stg.orders_raw ( + order_id TEXT, + order_date TEXT, + customer_id TEXT +); + +CREATE TABLE stg.order_items_raw ( + order_item_id TEXT, + order_id TEXT, + product_id TEXT, + qty TEXT, + price_at_sale TEXT +); + +CREATE TABLE stg.products_raw ( + product_id TEXT, + name TEXT +); + +-- 3. ODS: очищенные данные +CREATE TABLE ods.customers ( + customer_id INT, + email VARCHAR(100), + phone VARCHAR(20), + city VARCHAR(50) +); + +CREATE TABLE ods.orders ( + order_id INT, + order_date DATE, + customer_id INT +); + +CREATE TABLE ods.order_items ( + order_item_id INT, + order_id INT, + product_id INT, + qty INT, + price_at_sale NUMERIC(10,2) +); + +CREATE TABLE ods.products ( + product_id INT, + name VARCHAR(100) +); + +-- Первичные ключи в ODS (для ускорения и валидации) +ALTER TABLE ods.customers ADD PRIMARY KEY (customer_id); +ALTER TABLE ods.orders ADD PRIMARY KEY (order_id); +ALTER TABLE ods.order_items ADD PRIMARY KEY (order_item_id); +ALTER TABLE ods.products ADD PRIMARY KEY (product_id); + +-- 4. DDS: интегрированная модель + +-- dim_date: справочник дат (без первичного ключа — генерируется) +CREATE TABLE dds.dim_date ( + date_key INT PRIMARY KEY, + date_actual DATE NOT NULL, + year SMALLINT, + quarter SMALLINT, + month SMALLINT, + day SMALLINT, + weekday_name VARCHAR(10), + weekday_num SMALLINT, + is_first_week BOOLEAN +); + +-- dim_product: измерение "Товар" +CREATE TABLE dds.dim_product ( + product_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + product_bk INT NOT NULL, + product_name VARCHAR(100) NOT NULL +); + +-- dim_customer: измерение "Клиент" с SCD Type 2 +CREATE TABLE dds.dim_customer ( + customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_bk INT NOT NULL, + email VARCHAR(100), + phone VARCHAR(20), + city VARCHAR(50), + valid_from DATE NOT NULL, + valid_to DATE NOT NULL DEFAULT '9999-12-31', + is_current BOOLEAN NOT NULL DEFAULT TRUE +); + +-- fact_sales: факт "Продажи" +CREATE TABLE dds.fact_sales ( + sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_sk BIGINT NOT NULL, + product_sk BIGINT NOT NULL, + date_key INT NOT NULL, + quantity INT NOT NULL CHECK (quantity > 0), + amount NUMERIC(18,2) NOT NULL CHECK (amount >= 0) +); + +-- Внешние ключи (опционально — в продакшене часто отключают ради скорости) +ALTER TABLE dds.fact_sales + ADD CONSTRAINT fk_fact_customer FOREIGN KEY (customer_sk) REFERENCES dds.dim_customer(customer_sk), + ADD CONSTRAINT fk_fact_product FOREIGN KEY (product_sk) REFERENCES dds.dim_product(product_sk), + ADD CONSTRAINT fk_fact_date FOREIGN KEY (date_key) REFERENCES dds.dim_date(date_key); diff --git a/dwh-modeling/sql/02_dim.sql b/dwh-modeling/sql/02_dim.sql new file mode 100644 index 0000000..450b4b8 --- /dev/null +++ b/dwh-modeling/sql/02_dim.sql @@ -0,0 +1,182 @@ +-- =============================================== +-- DML-скрипт: загрузка и трансформация данных +-- Запускается ПОВТОРНО при каждой загрузке (идемпотентно!) +-- =============================================== + +-- 1. STG: имитация загрузки из источников (в реальности — COPY или INSERT из Kafka/NiFi) +-- ⚠️ В продакшене STG часто очищается перед загрузкой (TRUNCATE), либо используется партицирование по дате + +DELETE FROM stg.customers_raw; +DELETE FROM stg.orders_raw; +DELETE FROM stg.order_items_raw; +DELETE FROM stg.products_raw; + +INSERT INTO stg.customers_raw (customer_id, email, phone, city) VALUES +('101', 'a@ex.com', '700', 'Москва'), +('101', 'b@ex.com', '700', 'Москва'), +('102', 'c@ex.com', '701', 'СПб'); + +INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES +('5001', '2024-01-10', '101'), +('5002', '2024-02-05', '102'); + +INSERT INTO stg.order_items_raw (order_item_id, order_id, product_id, qty, price_at_sale) VALUES +('1', '5001', '9001', '2', '100.00'), +('2', '5001', '9002', '1', '50.00'), +('3', '5002', '9001', '1', '100.00'); + +INSERT INTO stg.products_raw (product_id, name) VALUES +('9001', 'Phone'), +('9002', 'Case'); + +-- 2. ODS: очистка и типизация +-- ⚠️ В продакшене используем UPSERT или incremental load, не TRUNCATE+INSERT + +TRUNCATE ods.customers, ods.orders, ods.order_items, ods.products; + +INSERT INTO ods.customers (customer_id, email, phone, city) +SELECT + customer_id::INT, + NULLIF(TRIM(email), ''), + NULLIF(TRIM(phone), ''), + NULLIF(TRIM(city), '') +FROM stg.customers_raw +WHERE customer_id ~ '^\d+$'; + +INSERT INTO ods.orders (order_id, order_date, customer_id) +SELECT + order_id::INT, + TO_DATE(order_date, 'YYYY-MM-DD'), + customer_id::INT +FROM stg.orders_raw +WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$'; + +INSERT INTO ods.order_items (order_item_id, order_id, product_id, qty, price_at_sale) +SELECT + order_item_id::INT, + order_id::INT, + product_id::INT, + NULLIF(qty, '')::INT, + NULLIF(price_at_sale, '')::NUMERIC(10,2) +FROM stg.order_items_raw +WHERE qty ~ '^\d+$' AND price_at_sale ~ '^\d+(\.\d+)?$'; + +INSERT INTO ods.products (product_id, name) +SELECT + product_id::INT, + TRIM(name) +FROM stg.products_raw +WHERE product_id ~ '^\d+$'; + +-- 3. DDS: dim_date — генерация календаря (идемпотентно: можно пересоздавать) +-- В реальности — делается ОДИН РАЗ, либо дополняется по мере необходимости + +DELETE FROM dds.dim_date; + +WITH RECURSIVE dates AS ( + SELECT DATE '2023-01-01' AS d + UNION ALL + SELECT d + INTERVAL '1 day' + FROM dates + WHERE d + INTERVAL '1 day' <= DATE '2027-12-31' +) +INSERT INTO dds.dim_date ( + date_key, date_actual, year, quarter, month, day, + weekday_name, weekday_num, is_first_week +) +SELECT + CAST(TO_CHAR(d, 'YYYYMMDD') AS INT), + d, + EXTRACT(YEAR FROM d)::SMALLINT, + EXTRACT(QUARTER FROM d)::SMALLINT, + EXTRACT(MONTH FROM d)::SMALLINT, + EXTRACT(DAY FROM d)::SMALLINT, + TO_CHAR(d, 'Day'), + EXTRACT(DOW FROM d)::SMALLINT, + d BETWEEN DATE_TRUNC('month', d) + AND DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day' + AND EXTRACT(DAY FROM d) <= 7 +FROM dates; + +-- 4. DDS: dim_product — полная перезагрузка (если товары редко меняются) +-- В реальности — инкрементальная загрузка по BK + +DELETE FROM dds.dim_product; + +INSERT INTO dds.dim_product (product_bk, product_name) +SELECT product_id, name +FROM ods.products; + +-- 5. DDS: dim_customer — SCD Type 2 (упрощённая версия для обучения) +-- В продакшене — используем алгоритм «детектирования изменений + UPSERT» +-- Здесь: перестраиваем всю историю на основе STG (для детерминированности) + +DELETE FROM dds.dim_customer; + +WITH ranked AS ( + SELECT + customer_id::INT AS customer_bk, + email, + phone, + city, + ROW_NUMBER() OVER ( + PARTITION BY customer_id + ORDER BY _load_ts, email + ) AS rn + FROM stg.customers_raw + WHERE customer_id ~ '^\d+$' +), +changes AS ( + SELECT + customer_bk, + email, + phone, + city, + -- Имитируем хронологию: +1 день на каждое изменение + '2023-01-01'::DATE + (rn - 1) * INTERVAL '1 day' AS eff_from + FROM ranked +), +final AS ( + SELECT + customer_bk, + email, + phone, + city, + eff_from::DATE AS valid_from, + COALESCE( + LEAD(eff_from) OVER (PARTITION BY customer_bk ORDER BY eff_from) - INTERVAL '1 day', + '9999-12-31'::DATE + ) AS valid_to, + CASE WHEN LEAD(eff_from) OVER (PARTITION BY customer_b_k ORDER BY eff_from) IS NULL + THEN TRUE ELSE FALSE END AS is_current + FROM changes +) +INSERT INTO dds.dim_customer (customer_bk, email, phone, city, valid_from, valid_to, is_current) +SELECT customer_bk, email, phone, city, valid_from, valid_to, is_current +FROM final; + +-- 6. DDS: fact_sales — загрузка фактов с учётом SCD +-- В продакшене — фильтруем по диапазону дат (инкрементально) + +DELETE FROM dds.fact_sales; + +INSERT INTO dds.fact_sales (customer_sk, product_sk, date_key, quantity, amount) +SELECT + dc.customer_sk, + dp.product_sk, + CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT), + oi.qty, + oi.price_at_sale * oi.qty +FROM ods.orders o +JOIN ods.order_items oi ON o.order_id = oi.order_id +JOIN ods.products p ON oi.product_id = p.product_id +JOIN dds.dim_product dp ON p.product_id = dp.product_bk +JOIN dds.dim_customer dc + ON o.customer_id = dc.customer_bk + AND o.order_date BETWEEN dc.valid_from AND dc.valid_to; + +-- 7. Проверка — вывод итогов (не часть ETL, но полезно для отладки) +-- В реальном пайплайне такие SELECT выносятся в отдельные скрипты или дашборды + +-- SELECT 'dim_customer count = ' || COUNT(*) FROM dds.dim_customer; +-- SELECT 'fact_sales count = ' || COUNT(*) FROM dds.fact_sales; diff --git a/dwh-modeling/sql/03_validation.sql b/dwh-modeling/sql/03_validation.sql new file mode 100644 index 0000000..b666668 --- /dev/null +++ b/dwh-modeling/sql/03_validation.sql @@ -0,0 +1,98 @@ +-- =============================================== +-- Проверки качества данных после загрузки STG→ODS→DDS +-- Запускается после 02_dml.sql +-- =============================================== + +-- 1. Проверка: dim_customer не пуста +DO $$ +BEGIN + ASSERT (SELECT COUNT(*) FROM dds.dim_customer) > 0, + 'ОШИБКА: таблица dds.dim_customer пуста — загрузка не прошла'; + RAISE NOTICE '✅ dim_customer: НЕ ПУСТА (всего строк: %)', + (SELECT COUNT(*) FROM dds.dim_customer); +END $$; + +-- 2. Проверка: fact_sales содержит все строки из order_items +DO $$ +DECLARE + expected_count INT := (SELECT COUNT(*) FROM ods.order_items); + actual_count INT := (SELECT COUNT(*) FROM dds.fact_sales); +BEGIN + ASSERT actual_count = expected_count, + FORMAT('ОШИБКА: в fact_sales %s строк, а в ods.order_items — %s. Разница: %s', + actual_count, expected_count, expected_count - actual_count); + RAISE NOTICE '✅ fact_sales: количество строк совпадает с ods.order_items (%)', actual_count; +END $$; + +-- 3. Проверка: у каждого факта есть валидная дата (date_key существует) +DO $$ +DECLARE missing_dates INT; +BEGIN + SELECT COUNT(*) INTO missing_dates + FROM dds.fact_sales f + LEFT JOIN dds.dim_date d ON f.date_key = d.date_key + WHERE d.date_key IS NULL; + + ASSERT missing_dates = 0, + FORMAT('ОШИБКА: %s фактов ссылаются на несуществующие даты (неверный date_key)', missing_dates); + RAISE NOTICE '✅ Все факты имеют валидные date_key'; +END $$; + +-- 4. Проверка SCD Type 2: у клиента 101 должно быть ≥2 версий (из-за смены email) +DO $$ +DECLARE version_count INT; +BEGIN + SELECT COUNT(*) INTO version_count + FROM dds.dim_customer + WHERE customer_bk = 101; + + ASSERT version_count >= 2, + FORMAT('ОШИБКА: у клиента 101 только %s версия, ожидается ≥2 (должна быть история)', version_count); + RAISE NOTICE '✅ SCD Type 2: клиент 101 имеет %s версий — история сохранена', version_count; +END $$; + +-- 5. Проверка: сумма amount = qty * price_at_sale (без округления) +DO $$ +DECLARE bad_rows INT; +BEGIN + SELECT COUNT(*) INTO bad_rows + FROM ( + SELECT + f.sale_id, + f.amount, + oi.qty * oi.price_at_sale AS expected_amount + FROM dds.fact_sales f + JOIN ods.orders o ON f.date_key = CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT) + JOIN ods.order_items oi ON o.order_id = oi.order_id + WHERE ROUND(f.amount, 2) <> ROUND(oi.qty * oi.price_at_sale, 2) + LIMIT 10 + ) mismatches; + + ASSERT bad_rows = 0, + 'ОШИБКА: обнаружены расхождения между amount и qty * price_at_sale'; + RAISE NOTICE '✅ Все суммы рассчитаны верно (amount = qty × price_at_sale)'; +END $$; + +-- =============================================== +-- Финальный отчёт для аналитика +-- =============================================== + +RAISE NOTICE '──────────────────────────────'; +RAISE NOTICE '📊 ОТЧЁТ: Выручка по клиентам (актуальные версии)'; +RAISE NOTICE '──────────────────────────────'; + +SELECT + dc.customer_bk AS "ID клиента", + dc.email AS "Email", + dc.city AS "Город", + SUM(f.quantity) AS "Всего товаров", + SUM(f.amount) AS "Выручка, ₽" +FROM dds.fact_sales f +JOIN dds.dim_customer dc + ON f.customer_sk = dc.customer_sk + AND dc.is_current -- только актуальная версия +GROUP BY dc.customer_bk, dc.email, dc.city +ORDER BY SUM(f.amount) DESC; + +RAISE NOTICE '──────────────────────────────'; +RAISE NOTICE '✅ Все проверки пройдены. DWH готов к построению витрин.';