From 973883385bf44fc6939a72378b5fcf0385816c26 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 14:05:16 +0300 Subject: [PATCH 1/8] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20DWH-?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=20Data=20Vault,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D0=BF=D0=B5=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D0=BC=D0=B0=D1=88=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + dwh-modeling/README.md | 83 +++--------------------------------------- 2 files changed, 7 insertions(+), 77 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/dwh-modeling/README.md b/dwh-modeling/README.md index 6bafb58..b3b9ef2 100644 --- a/dwh-modeling/README.md +++ b/dwh-modeling/README.md @@ -281,7 +281,7 @@ AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid ``` и получаем актуальный на тот день email и город. -> 🔍 Подробнее про SCD — в отдельной статье [Slow Changing Dimensions](SCD.md) (сравнение Type 1/2/3, паттерны обновления). +> 🔍 Подробнее про SCD — в отдельной статье [Slowly Changing Dimensions](SCD.md) (сравнение Type 1/2/3, паттерны обновления). Теперь, когда мы разобрались, что такое факты, измерения и SCD, давайте посмотрим, как именно можно устроить слой DDS внутри — есть несколько вариантов. @@ -374,82 +374,9 @@ AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid - новые источники проще прикручивать; - меньше шансов «сломать» старые отчёты. ---- +Data Vault хорошо подходит там, где много разнородных источников, нужна полная история изменений и прозрачный аудит. За гибкость приходится платить сложностью модели и количеством таблиц — поэтому для небольших проектов (2–5 источников, маленькая команда) DV почти наверняка избыточен. -#### Чем DV отличается от 3NF и Звезды - -Если сильно упростить: - -- В **3NF/Звезде** мы часто смешиваем: - - бизнес-ключ, - - текущие атрибуты, - - историю (SCD2) - — всё это в одной таблице измерения. - -- В **Data Vault** это *разнесено*: - - Hub — только бизнес-ключ; - - Satellite — только атрибуты + история; - - Link — только связи между сущностями. - -За это приходится платить сложностью модели и количеством таблиц. Зато DV хорошо выдерживает: -- много разнородных источников; -- «грязные» данные; -- жёсткие требования по аудиту и трассировке. - ---- - -#### Raw Vault и Business Vault — два слоя - -Часто говорят «Raw Vault» и «Business Vault». Грубо: - -- **Raw Vault** — «как прилетело из источников». - Хабы, линкы и сателлиты, максимально близкие к исходным данным. - Задача: надёжно собрать и сохранить **полную историю**. - -- **Business Vault** — «как удобно считать дальше». - На основе Raw Vault появляются: - - служебные таблицы (PIT, Bridge и т.п.), - - подготовленные представления под витрины и отчёты, - - бизнес-правила (например, что считать «активным клиентом»). - -Дальше поверх этого уже строятся **обычные витрины в формате Звезды**, с которыми работают аналитики. - -Если примерить это к классическим слоям `stg → ods → dds → dm`, то **очень грубо** можно думать так: - -- `stg` всё равно остаётся как «приземление» (landing) из источников; -- **Raw Vault** по духу ближе к **ODS**: мало бизнес-логики, зато полная история и интеграция из разных систем; -- **Business Vault** ближе к **DDS**: здесь уже живут бизнес-правила и подготовка данных к витринам; -- `dm` по-прежнему остаётся витринами в формате Звезды, с которыми работают аналитики и BI. - -Важно: это именно *аналогия для понимания*, а не жёсткое правило проектирования. - ---- - -#### Когда DV вам, скорее всего, рано - -Если у вас: - -- 2–5 источников, -- небольшая команда (1–2 инженера + аналитик), -- задачи уровня «сделать первые отчёты», - -то **Data Vault почти наверняка избыточен**. -Чаще всего хватает связки: - -> `stg → ods → dds (3NF или простая Звезда с SCD2) → dm (Звезда)` - ---- - -#### Что важно запомнить из этой статьи - -Для этой статьи достаточно: - -- знать, что **Data Vault** — это способ строить хранилище как **конструктор из Hub/Link/Satellite**, -- понимать, что он нужен в первую очередь там, где: - - много систем-источников, - - нужна *полная* история и прозрачный аудит. - -Детали (Raw vs Business Vault, PIT/Bridge, DV 1.0 vs 2.0 и т.п.) — это уже тема для отдельной, взрослой статьи. +> 🔍 Подробнее про Data Vault — сравнение с 3NF/Звездой, Raw и Business Vault, когда внедрять — в отдельной статье [DataVault: как пережить бурную жизнь источников](DataVault.md). --- @@ -577,6 +504,8 @@ GROUP BY d.date_actual, p.product_name, c.customer_segment; > 💡 **Материализованное представление (MATERIALIZED VIEW)** — это «кэш» результата. Обновляется по расписанию (например, ночью). +✏️ **Попробуйте сами:** [Домашка: статусы клиента от STG до DDS (и немного DM)](Homework_Customer_Status_DDS_DM.md) — пройдёте тот же путь, но самостоятельно. + --- ## 8. Как выбрать модель данных? Советы от практиков @@ -628,7 +557,7 @@ GROUP BY d.date_actual, p.product_name, c.customer_segment; Почему: ему нужны готовые метрики без сложных JOIN’ов. Звезда даёт понятные таблицы: «продажи по дням и товарам» — без углубления в атомарные сущности. — **BI-разработчик в Power BI / Tableau** → **Звезда** - Почему: все инструменты визуализации оптимизированы под star schema. Один факт + несколько измерений = быстрые отчёты и простою модель. + Почему: все инструменты визуализации оптимизированы под star schema. Один факт + несколько измерений = быстрые отчёты и простую модель. — **Инженер ML (Data Scientist / ML-инженер)** → **3NF или сырые ODS-таблицы** Почему: для фичей нужны атомарные события и детальные атрибуты. Машинное обучение ценит полноту и детализацию данных больше, чем удобство отчётов. From 8973eea06411a91c02cd437c7c47a24db7c60dc9 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 14:29:23 +0300 Subject: [PATCH 2/8] =?UTF-8?q?feat(docs):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20COMMIT=5FRULES.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужна единая спецификация для сообщений коммитов - облегчение code review и читаемости истории - Что: - создан COMMIT_RULES.md с правилами Conventional Commits - адаптированы scopes под репозиторий: sql, modeling, bookings, docs, data - обновлена секция в AGENTS.md с ссылкой на полные правила - Проверка: - git log --oneline -1 - cat COMMIT_RULES.md | head -20 --- AGENTS.md | 6 +- COMMIT_RULES.md | 220 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 COMMIT_RULES.md diff --git a/AGENTS.md b/AGENTS.md index 9c86596..ec1f513 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,10 @@ - For `postgres-bookings`, after modifications run `docker compose up -d && ./psql_sh` and verify simple queries such as `SELECT COUNT(*) FROM bookings.flights;`. ## Commit & Pull Request Guidelines -- Commit messages are short, imperative or descriptive phrases (often in Russian), e.g. `Добавлено оглавление`, `Переработка структуры`; group related edits into a single commit. -- Pull requests should focus on one topic, include a brief context, list of changes, and manual steps to reproduce or validate (commands you ran, expected results). + +**Required:** Read [COMMIT_RULES.md](COMMIT_RULES.md) before making commits. + +Pull requests should focus on one topic, include a brief context, list of changes, and manual steps to reproduce or validate (commands you ran, expected results). ## Security & Configuration Tips - Do not commit personal `.env` files or credentials; use local overrides only. diff --git a/COMMIT_RULES.md b/COMMIT_RULES.md new file mode 100644 index 0000000..919731a --- /dev/null +++ b/COMMIT_RULES.md @@ -0,0 +1,220 @@ +# Commit Rules + +Unified commit style for all project contributors. Follows [Conventional Commits](https://www.conventionalcommits.org/) specification. + +## Language + +- **Primary language**: Russian +- If language is not specified, use Russian +- For AI-generated commits, Russian is mandatory unless task explicitly sets `lang:en` +- English is allowed only by explicit instruction (`lang:en`) or external collaboration requirements +- Do not mix languages in free-text parts of one commit message (subject + body + footer) +- Conventional Commit `type(scope)` stays in English +- Technical terms (PostgreSQL, SQL, DWH, DDL, DML) keep as-is + +## Header Format + +``` +(): +``` + +- Maximum header length: 72 characters +- For Russian subject, use result form (e.g. "добавлено", "исправлено", "обновлено") +- For English subject, use imperative present form (e.g. "add", "fix", "update") +- For English subject, do not use past forms (e.g. "added", "fixed", "updated") +- No trailing period +- Keep subject specific; avoid vague messages like "update", "fix bug", "changes" + +### Allowed `type` + +| Type | Description | +|------|-------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `refactor` | Code restructuring without behavior change | +| `docs` | Documentation only | +| `test` | Tests, checks, validations | +| `chore` | Maintenance (configs, scripts, hooks) | +| `ci` | CI/CD changes | +| `perf` | Performance optimization | +| `revert` | Revert previous commit | + +### Recommended `scope` for this repo + +| Scope | Used for | +|-------|----------| +| `sql` | SQL scripts in `dwh-modeling/sql/` | +| `modeling` | DWH modeling docs, articles, schemas in `dwh-modeling/` | +| `bookings` | Docker PostgreSQL demo in `postgres-bookings/` | +| `docs` | Documentation, README, guides | +| `data` | Data files (CSV, fixtures) | + +## Body Structure + +For non-trivial changes, body is required. Use bullet points for readability. + +Body is considered required when at least one condition is true: +- behavior or API/contract changed +- migration, rollback risk, or compatibility impact exists +- more than one meaningful file/module changed +- fix is non-obvious from header alone + +### Multiline body in CLI (important) + +- Do not pass body as one quoted string with `\n` (it will be stored literally). +- Use multiple `-m` flags, or `-F` with heredoc. + +Correct: + +```bash +git commit \ + -m "feat(sql): добавлена валидация данных для DWH" \ + -m "- Зачем: + - нужна проверка целостности перед загрузкой +- Что: + - добавлен скрипт 04_validation.sql + - добавлены проверки на NULL и уникальность +- Проверка: + - psql -f dwh-modeling/sql/04_validation.sql" +``` + +Also correct: + +```bash +git commit -F- <<'MSG' +feat(sql): добавлена валидация данных для DWH + +- Зачем: + - нужна проверка целостности перед загрузкой +- Что: + - добавлен скрипт 04_validation.sql + - добавлены проверки на NULL и уникальность +- Проверка: + - psql -f dwh-modeling/sql/04_validation.sql +MSG +``` + +### Template (Russian - default) + +``` +(): <краткое описание результата> + +- Зачем: + - причина изменения +- Что: + - ключевое изменение 1 + - ключевое изменение 2 +- Проверка: + - как проверено +``` + +### Template (English - only with `lang:en`) + +``` +(): + +- Why: + - reason for change +- What: + - key change 1 + - key change 2 +- Check: + - how verified (command/test/smoke-check) +``` + +## Commit Scope Rules + +- One commit = one logical task +- Don't mix feature changes with large refactoring +- Update docs in the same commit where behavior changes + +## Breaking Changes + +Use `!` in header for breaking changes: +``` +feat(sql)!: rename stg_orders column contract +``` + +Add footer: +``` +BREAKING CHANGE: column order_date renamed to created_at +``` + +## Examples + +### Good examples + +``` +feat(sql): добавлен скрипт загрузки DM-слоя + +- Зачем: + - нужны витрины для аналитики +- Что: + - добавлен 06_dml_dm.sql с загрузкой фактов и измерений + - добавлены индексы для оптимизации запросов +- Проверка: + - psql -f dwh-modeling/sql/06_dml_dm.sql + - SELECT COUNT(*) FROM dm.fact_orders; +``` + +``` +fix(bookings): исправлен порт в docker-compose.yml + +- Зачем: + - конфликт с локальным PostgreSQL на 5432 +- Что: + - порт хоста изменен на 5433 +- Проверка: + - docker compose up -d + - psql -h localhost -p 5433 -U postgres +``` + +``` +docs(modeling): обновлена схема Data Vault после ревью +``` + +``` +chore(docs): синхронизировано оглавление README +``` + +### Bad examples (don't do this) + +``` +❌ added sql script # no type, past tense +❌ feat: добавлен скрипт # no scope +❌ fix: исправлен баг # no scope, vague and non-actionable +❌ feat(sql): added new table # past tense in English subject +❌ feat(sql): add script and fix validation and update docs # multiple concerns +❌ feat(docs): add README и почини SQL # mixed languages in one message +``` + +## Quick Reference + +```bash +# Feature +feat(scope): добавлена новая возможность + +# Bug fix +fix(scope): исправлена проблема + +# Documentation +docs(scope): обновлена документация + +# Refactoring +refactor(scope): упрощена структура без изменения поведения + +# Performance +perf(scope): ускорено выполнение + +# Maintenance +chore(scope): обновлены служебные настройки + +# Feature (lang:en) +feat(scope): add new capability + +# Bug fix (lang:en) +fix(scope): correct response parsing + +# Documentation (lang:en) +docs(scope): update setup guide +``` From 45f8f743781c3956723bf096d2061c38c6b182ea Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 14:31:10 +0300 Subject: [PATCH 3/8] =?UTF-8?q?refactor(modeling):=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D1=8B=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB?= =?UTF-8?q?=D1=8B=20DWH=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D0=B0=D1=82=D0=B0=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - убрать путаницу, дублирование и неточности в демо-скриптах и домашке - Что: - CSV: заголовок load_ts → _load_ts во всех файлах (совпадает с именем в таблицах) - домашка: убраны оговорки о расхождении load_ts/_load_ts, добавлена ссылка на эталонное решение - 09_dml_hw_customer_status_solution.sql: эталонное решение скопировано из ветки solution/hw_customer_status в основную - 02_dml: добавлена карта загрузки в шапку (что откуда строится) - 05_ddl_dm + 06_dml_dm: total_orders → total_line_items (название точнее отражает содержимое) - Проверка: - визуальная проверка diff, скрипты не запускались (демо-стенд не поднят) Co-Authored-By: Claude Opus 4.6 --- .../Homework_Customer_Status_DDS_DM.md | 18 +- dwh-modeling/README.md | 2 +- dwh-modeling/data/customer_status_events.csv | 2 +- .../data/customer_status_events_increment.csv | 2 +- dwh-modeling/data/customers.csv | 2 +- dwh-modeling/sql/02_dml_stg-dds.sql | 7 + dwh-modeling/sql/05_ddl_dm.sql | 2 +- dwh-modeling/sql/06_dml_dm.sql | 4 +- .../09_dml_hw_customer_status_solution.sql | 276 ++++++++++++++++++ 9 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 dwh-modeling/sql/09_dml_hw_customer_status_solution.sql diff --git a/dwh-modeling/Homework_Customer_Status_DDS_DM.md b/dwh-modeling/Homework_Customer_Status_DDS_DM.md index 55b6c2f..41bc004 100644 --- a/dwh-modeling/Homework_Customer_Status_DDS_DM.md +++ b/dwh-modeling/Homework_Customer_Status_DDS_DM.md @@ -30,7 +30,7 @@ Структура файла: ```text -customer_id,status,event_ts,_load_id,load_ts +customer_id,status,event_ts,_load_id,_load_ts 101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00 ... ``` @@ -41,7 +41,7 @@ customer_id,status,event_ts,_load_id,load_ts - `status` — статус клиента в CRM (`new`, `active`, `vip`, `churned`); - `event_ts` — момент, когда статус сменился в CRM; - `_load_id` — идентификатор батча загрузки; -- `load_ts` — момент, когда данные попали в DWH (в таблицах STG/ODS эта колонка будет называться `_load_ts`, но по смыслу это то же самое время загрузки). +- `_load_ts` — момент, когда данные попали в DWH. Файл содержит несколько клиентов и несколько смен статуса по каждому — этого достаточно, чтобы отработать SCD2. @@ -94,13 +94,13 @@ INSERT INTO stg.customer_status_raw (customer_id, status, event_ts, _load_id, _l ('101','churned','2024-09-01 12:15:00','batch_20240901_1300','2024-09-01 13:00:00'); ``` -> 💡 Здесь `_load_ts` — это время загрузки (в CSV оно называется `load_ts`). +> 💡 Здесь `_load_ts` — это время загрузки. #### Вариант B: загрузить CSV Можно загрузить файл `dwh-modeling/data/customer_status_events.csv` в таблицу `stg.customer_status_raw`: -- **Через DBeaver**: Import Data → CSV → `stg.customer_status_raw` (колонку `load_ts` маппить в `_load_ts`). +- **Через DBeaver**: Import Data → CSV → `stg.customer_status_raw`. - **Через `psql` в контейнере (`./psql_sh`)**: без установки `psql` на хост. Способ: передайте CSV в `psql` через STDIN и выполните `\copy ... FROM STDIN`: @@ -126,7 +126,7 @@ SELECT * FROM stg.customer_status_raw LIMIT 10; - привести: - `customer_id` → `INT`, - `status` → `VARCHAR(20)` (можно оставить как есть), - - `event_ts` и `load_ts` → `TIMESTAMP` (в DWH-таблицах эта колонка будет лежать как `_load_ts`); + - `event_ts` и `_load_ts` → `TIMESTAMP`; - аккуратно обработать возможные пустые значения (если бы они были); - заполнить `_load_id` и `_load_ts` в `ods.customer_status`. @@ -292,3 +292,11 @@ ORDER BY date_actual, status; - при желании — собрать простую витрину в `dm`. Если что‑то не получается — можно разбирать решения по шагам вместе с ментором: от простого `SELECT` из STG до полноценного SCD2 в DDS. + +--- + +## 8. Эталонное решение + +Когда выполните домашку и захотите сверить результат — готовое решение лежит в файле [`09_dml_hw_customer_status_solution.sql`](sql/09_dml_hw_customer_status_solution.sql). + +Постарайтесь не подглядывать до того, как напишете свой вариант — основная ценность задания именно в самостоятельном разборе. diff --git a/dwh-modeling/README.md b/dwh-modeling/README.md index b3b9ef2..b269be5 100644 --- a/dwh-modeling/README.md +++ b/dwh-modeling/README.md @@ -720,7 +720,7 @@ SELECT 'OK' WHERE EXISTS ( [`customers.csv`](data/customers.csv): ```csv -customer_id,email,phone,city,event_ts,_load_id,load_ts +customer_id,email,phone,city,event_ts,_load_id,_load_ts 101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00 102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00 101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00 diff --git a/dwh-modeling/data/customer_status_events.csv b/dwh-modeling/data/customer_status_events.csv index 40a586e..218656e 100644 --- a/dwh-modeling/data/customer_status_events.csv +++ b/dwh-modeling/data/customer_status_events.csv @@ -1,4 +1,4 @@ -customer_id,status,event_ts,_load_id,load_ts +customer_id,status,event_ts,_load_id,_load_ts 101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00 101,active,2024-02-15 10:30:00,batch_20240215_1100,2024-02-15 11:00:00 101,vip,2024-05-10 11:00:00,batch_20240510_1200,2024-05-10 12:00:00 diff --git a/dwh-modeling/data/customer_status_events_increment.csv b/dwh-modeling/data/customer_status_events_increment.csv index 83d32d6..5bae97e 100644 --- a/dwh-modeling/data/customer_status_events_increment.csv +++ b/dwh-modeling/data/customer_status_events_increment.csv @@ -1,4 +1,4 @@ -customer_id,status,event_ts,_load_id,load_ts +customer_id,status,event_ts,_load_id,_load_ts 101,active,2024-11-15 09:00:00,batch_20241115_1000,2024-11-15 10:00:00 102,active,2024-05-05 09:30:00,batch_20240505_1000,2024-05-05 10:00:00 103,active,2024-03-20 12:00:00,batch_20240320_1300,2024-03-20 13:00:00 diff --git a/dwh-modeling/data/customers.csv b/dwh-modeling/data/customers.csv index 229fb6f..e9f2d25 100644 --- a/dwh-modeling/data/customers.csv +++ b/dwh-modeling/data/customers.csv @@ -1,4 +1,4 @@ -customer_id,email,phone,city,event_ts,_load_id,load_ts +customer_id,email,phone,city,event_ts,_load_id,_load_ts 101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00 102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00 101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00 diff --git a/dwh-modeling/sql/02_dml_stg-dds.sql b/dwh-modeling/sql/02_dml_stg-dds.sql index dd4ed1f..1cf9dbf 100644 --- a/dwh-modeling/sql/02_dml_stg-dds.sql +++ b/dwh-modeling/sql/02_dml_stg-dds.sql @@ -2,6 +2,13 @@ -- DML-скрипт: загрузка и трансформация данных -- Запускается ПОВТОРНО при каждой загрузке (идемпотентно!) -- =============================================== +-- +-- Карта загрузки в этом скрипте: +-- STG → ODS: orders, order_items, products, customers (снимок: одна строка на BK) +-- STG → DDS: dim_customer (SCD2, full backfill напрямую из STG — см. комментарий к п.5) +-- ODS → DDS: dim_product, fact_sales +-- отдельно: dim_date (генерация календаря) +-- -- 1. STG: имитация загрузки из источников (в реальности — COPY или INSERT из Kafka/NiFi) -- ⚠️ В продакшене STG часто очищается перед загрузкой (TRUNCATE), либо используется партицирование по дате diff --git a/dwh-modeling/sql/05_ddl_dm.sql b/dwh-modeling/sql/05_ddl_dm.sql index 642feba..214bb46 100644 --- a/dwh-modeling/sql/05_ddl_dm.sql +++ b/dwh-modeling/sql/05_ddl_dm.sql @@ -21,7 +21,7 @@ CREATE TABLE dm.mart_customer_360 ( customer_bk INT NOT NULL, first_order_date DATE, last_order_date DATE, - total_orders INT NOT NULL, + total_line_items INT NOT NULL, total_items INT NOT NULL, lifetime_value NUMERIC(18,2) NOT NULL, last_email VARCHAR(100), diff --git a/dwh-modeling/sql/06_dml_dm.sql b/dwh-modeling/sql/06_dml_dm.sql index 29d3b63..d4b81ab 100644 --- a/dwh-modeling/sql/06_dml_dm.sql +++ b/dwh-modeling/sql/06_dml_dm.sql @@ -33,14 +33,14 @@ GROUP BY d.date_actual, p.product_name, -- Считаем суммы по всей истории его покупок INSERT INTO dm.mart_customer_360 ( customer_bk, first_order_date, last_order_date, - total_orders, total_items, lifetime_value, + total_line_items, total_items, lifetime_value, last_email, last_city ) SELECT c.customer_bk, MIN(d.date_actual) AS first_order_date, MAX(d.date_actual) AS last_order_date, - COUNT(DISTINCT f.sale_id) AS total_orders, -- считаем строки факта (продажи), не бизнес-заказы + COUNT(DISTINCT f.sale_id) AS total_line_items, -- строки факта (позиции продаж), не бизнес-заказы SUM(f.quantity) AS total_items, SUM(f.amount) AS lifetime_value, -- Берём самый свежий email и город клиента diff --git a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql new file mode 100644 index 0000000..37c3661 --- /dev/null +++ b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql @@ -0,0 +1,276 @@ +-- =============================================== +-- 09_dml_hw_customer_status_solution.sql +-- Решение домашки: статусы клиента (STG -> ODS -> DDS SCD2 -> DM) +-- +-- Что делает этот файл: +-- 1) Перекладывает события статусов в ODS (приводит типы, чистит пустое). +-- 2) Строит DDS-измерение со "встроенной историей" (SCD2): периоды valid_from/valid_to. +-- 3) Показывает пример обновления DDS маленькой порцией (инкремент): закрыть старое + вставить новое. +-- 4) Собирает простую витрину в DM: сколько клиентов в каком статусе по дням. +-- +-- Как запускать: +-- - для первого знакомства можно запускать файл целиком; +-- - если хотите потренировать инкремент (п.3): добавьте новые события -> обновите ODS -> запустите блок 3 ещё раз. +-- +-- Важно: +-- - здесь часто используется TRUNCATE (полная очистка), чтобы было легко повторять домашку; +-- - в реальном DWH так делают не всегда, но для обучения это удобнее. +-- +-- Предусловия (DDL + данные в STG): +-- 1) dwh-modeling/sql/01_ddl_stg-dds.sql +-- 2) dwh-modeling/sql/05_ddl_dm.sql +-- 3) dwh-modeling/sql/07_ddl_hw_customer_status.sql +-- 4) stg.customer_status_raw заполнена (см. dwh-modeling/Homework_Customer_Status_DDS_DM.md) +-- =============================================== + +-- ========================================================== +-- 1) ODS: очистка и типизация (full refresh) +-- ========================================================== + +-- Идея: +-- - STG хранит "как пришло" (обычно TEXT); +-- - ODS хранит "аккуратно": правильные типы + простая чистка. +-- Для простоты пересобираем ODS с нуля. + +TRUNCATE ods.customer_status; + +INSERT INTO ods.customer_status ( + customer_id, status, event_ts, _load_id, _load_ts +) +SELECT + s.customer_id::INT AS customer_id, + NULLIF(trim(s.status), '') AS status, + NULLIF(trim(s.event_ts), '')::TIMESTAMP AS event_ts, + s._load_id, + COALESCE(s._load_ts, now()) AS _load_ts +FROM stg.customer_status_raw s +WHERE s.customer_id ~ '^\d+$' + AND NULLIF(trim(s.event_ts), '') IS NOT NULL + AND NULLIF(trim(s.status), '') IS NOT NULL; + +-- ========================================================== +-- 2) DDS: начальная загрузка SCD2 (full refresh) +-- ========================================================== + +-- Идея SCD2 простыми словами: +-- - одна строка = один период, когда статус был одним и тем же; +-- - valid_from = с какого дня статус "начался"; +-- - valid_to = с какого дня статус "закончился" (NULL = текущий статус); +-- - интервалы считаем так: [valid_from, valid_to) (valid_to не включаем). +-- - чтобы найти статус "на дату D": +-- D >= valid_from AND (valid_to IS NULL OR D < valid_to) +-- +-- Упрощение для домашки: +-- - считаем, что у клиента нет двух разных смен статуса в один день. + +TRUNCATE dds.dim_customer_status; + +WITH src AS ( + -- src: события из ODS + "контрольная сумма" статуса. + -- Так проще проверять, поменялся статус или остался тем же. + SELECT + customer_id AS customer_bk, + status, + event_ts, + md5(lower(coalesce(status, ''))) AS hashdiff + FROM ods.customer_status +), +ordered AS ( + -- ordered: для каждого клиента смотрим "какая версия была до этого" (LAG) + SELECT + *, + lag(hashdiff) OVER ( + PARTITION BY customer_bk + ORDER BY event_ts + ) AS prev_hash + FROM src +), +changes AS ( + -- changes: оставляем только первое состояние и реальные изменения статуса + SELECT * + FROM ordered + WHERE prev_hash IS DISTINCT FROM hashdiff OR prev_hash IS NULL +), +framed AS ( + -- framed: превращаем изменения в периоды (valid_to = дата следующего события через LEAD) + SELECT + customer_bk, + status, + hashdiff, + event_ts::DATE AS valid_from, + lead(event_ts::DATE) OVER ( + PARTITION BY customer_bk + ORDER BY event_ts + ) AS valid_to + FROM changes +) +INSERT INTO dds.dim_customer_status ( + customer_bk, status, hashdiff, + valid_from, valid_to, + created_at, updated_at +) +SELECT + customer_bk, status, hashdiff, + valid_from, valid_to, + now(), now() +FROM framed +ORDER BY customer_bk, valid_from; + +-- ========================================================== +-- 3) DDS: инкрементальная загрузка SCD2 (по последним событиям) +-- ========================================================== + +-- Этот блок нужен, чтобы показать "как это обычно обновляют": +-- после новой порции событий мы: +-- 1) берём по каждому клиенту самое позднее событие из ODS; +-- 2) сравниваем его с текущей версией в DDS (valid_to IS NULL); +-- 3) если статус изменился — закрываем старую версию и вставляем новую. +-- +-- Ограничение учебного варианта (в домашке можно игнорировать): +-- - если вы добавили событие "задним числом" со старой датой, этот блок не пересоберёт всю историю. +-- Для такого кейса обычно делают отдельную логику или full refresh. +-- +-- Примечание: +-- - в этом файле блок 2 (full refresh) запускается раньше, поэтому сразу после него +-- блок 3, скорее всего, ничего не изменит. Зато его можно повторять после новых событий. + +BEGIN; + -- 3.1) Закрываем предыдущую актуальную версию + WITH ranked AS ( + -- ranked: выбираем "самое свежее" событие на клиента. + -- Если event_ts одинаковый, берём то, что загрузилось позже (_load_ts). + SELECT + customer_id AS customer_bk, + status, + event_ts::DATE AS eff_date, + md5(lower(coalesce(status, ''))) AS hashdiff, + row_number() OVER ( + PARTITION BY customer_id + ORDER BY event_ts DESC, _load_ts DESC + ) AS rn + FROM ods.customer_status + WHERE event_ts IS NOT NULL + ), + delta AS ( + -- delta: ровно одна строка на клиента (самое свежее событие) + SELECT * FROM ranked WHERE rn = 1 + ), + current AS ( + -- current: текущие версии в DDS (valid_to IS NULL) + SELECT d.* + FROM dds.dim_customer_status d + WHERE d.valid_to IS NULL + ) + UPDATE dds.dim_customer_status d + SET valid_to = x.eff_date, + updated_at = now() + FROM ( + -- x: кого "закрываем": + -- клиент уже есть в DDS, и статус действительно изменился. + SELECT + t.customer_bk, + t.eff_date, + c.customer_status_sk + FROM delta t + JOIN current c + ON c.customer_bk = t.customer_bk + WHERE c.hashdiff <> t.hashdiff + AND t.eff_date > c.valid_from -- не создаём период нулевой/отрицательной длины + ) x + WHERE d.customer_status_sk = x.customer_status_sk + AND d.valid_to IS NULL; + + -- 3.2) Вставляем новую версию + WITH ranked AS ( + -- ranked/delta/current повторяем отдельно, чтобы блок INSERT читался отдельно от UPDATE + SELECT + customer_id AS customer_bk, + status, + event_ts::DATE AS eff_date, + md5(lower(coalesce(status, ''))) AS hashdiff, + row_number() OVER ( + PARTITION BY customer_id + ORDER BY event_ts DESC, _load_ts DESC + ) AS rn + FROM ods.customer_status + WHERE event_ts IS NOT NULL + ), + delta AS ( + SELECT * FROM ranked WHERE rn = 1 + ), + current AS ( + SELECT d.* + FROM dds.dim_customer_status d + WHERE d.valid_to IS NULL + ), + to_insert AS ( + -- to_insert: кого "вставляем": + -- 1) новый клиент (в current нет строки); + -- 2) изменившийся клиент (статус поменялся). + SELECT + t.customer_bk, + t.status, + t.hashdiff, + t.eff_date + FROM delta t + LEFT JOIN current c + ON c.customer_bk = t.customer_bk + WHERE c.customer_status_sk IS NULL + OR (c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from) + ) + INSERT INTO dds.dim_customer_status ( + customer_bk, status, hashdiff, + valid_from, valid_to, + created_at, updated_at + ) + SELECT + t.customer_bk, t.status, t.hashdiff, + t.eff_date, NULL, + now(), now() + FROM to_insert t + -- защита от повторного запуска: не вставляем одну и ту же версию (BK + valid_from) второй раз + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_customer_status d + WHERE d.customer_bk = t.customer_bk + AND d.valid_from = t.eff_date + ); +COMMIT; + +-- ========================================================== +-- 4) DM: витрина статусов клиентов по датам (full refresh) +-- ========================================================== + +-- Витрина "снимок на дату": +-- для каждого дня считаем, сколько клиентов было в каждом статусе. +-- Берём календарь dds.dim_date и подбираем статус по периоду valid_from/valid_to. + +CREATE TABLE IF NOT EXISTS dm.mart_customer_status_daily ( + date_actual DATE NOT NULL, + status VARCHAR(20) NOT NULL, + customers_cnt INT NOT NULL +); + +TRUNCATE dm.mart_customer_status_daily; + +WITH bounds AS ( + SELECT + min(valid_from) AS date_from, + max(coalesce(valid_to, valid_from)) AS date_to + FROM dds.dim_customer_status +) +INSERT INTO dm.mart_customer_status_daily ( + date_actual, status, customers_cnt +) +SELECT + d.date_actual, + s.status, + COUNT(DISTINCT s.customer_bk) AS customers_cnt +FROM dds.dim_date d +JOIN bounds b + ON d.date_actual BETWEEN b.date_from AND b.date_to +JOIN dds.dim_customer_status s + ON d.date_actual >= s.valid_from + AND (s.valid_to IS NULL OR d.date_actual < s.valid_to) +GROUP BY d.date_actual, s.status +ORDER BY d.date_actual, s.status; From 1d9953905257a51698ae2ba98153133c501a20a4 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 14:41:42 +0300 Subject: [PATCH 4/8] =?UTF-8?q?docs(modeling):=20=D1=80=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BD=D1=8B=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8B=203NF=20=D0=B8=20=D0=97=D0=B2=D0=B5=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=BC=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - разделы 3NF и Звезда были слишком краткими для учебного материала, менти не видел разницу между моделями на практике - Что: - 3NF: добавлена mermaid-диаграмма с dim_city и SQL-запрос (3 JOIN) - Звезда: добавлена явная связь с разделом 5, SQL-запрос (2 JOIN) для контраста - оба примера отвечают на один вопрос: «сколько потратил клиент из Москвы?» - Проверка: - визуальная проверка diff Co-Authored-By: Claude Opus 4.6 --- dwh-modeling/README.md | 73 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/dwh-modeling/README.md b/dwh-modeling/README.md index b269be5..fa80267 100644 --- a/dwh-modeling/README.md +++ b/dwh-modeling/README.md @@ -291,7 +291,7 @@ AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid В DDS мы можем хранить данные по-разному. Это не «правильно/неправильно», а **выбор под задачу**. -### 1. 3NF (третья нормальная форма) +### 1. 3NF (третья нормальная форма) *Источник: Билл Инмон (Bill Inmon)* Если упростить, 3NF - это когда данные о разных бизнес-сущностях хранятся в отдельных таблицах и связываются ключами: клиент, заказ, город, регион, страна и т.д. Вместо одной большой таблицы с большим числом дублирующихся данных мы получаем цепочку таблиц, связанных ключами: `Заказ → Клиент → Город → Регион → Страна`. JOIN-ов становится больше, зато одно и то же свойство (например, название города) хранится в одном месте, а не дублируется в каждой строке заказа. @@ -303,6 +303,53 @@ AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid * Данные о сущностях разнесены по отдельным таблицам: клиент, заказ, продукт. * Минимум дублирования: общие атрибуты хранятся в одном месте, таблицы связаны ключами. +#### Как выглядел бы наш магазин в 3NF + +В нашем примере `city` лежит прямо в `dim_customer`. В 3NF город стал бы отдельной таблицей, чтобы название хранилось в одном месте: + +```mermaid +erDiagram + dim_city ||--o{ dim_customer : "город" + dim_customer ||--o{ fact_sales : "клиент" + + dim_city { + int city_id PK + varchar city_name "Москва, СПб, ..." + } + + dim_customer { + bigint customer_sk PK + int customer_bk + varchar email + int city_id FK "ссылка на dim_city" + date valid_from + date valid_to + } + + fact_sales { + bigint sale_id PK + bigint customer_sk FK + int date_key FK + int quantity + decimal amount + } +``` + +Теперь, чтобы узнать **«сколько потратил клиент из Москвы за январь 2024?»**, нужно пройти по цепочке: + +```sql +-- 3NF: три JOIN, чтобы добраться до города +SELECT SUM(f.amount) +FROM dds.fact_sales f +JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk +JOIN dds.dim_city ct ON c.city_id = ct.city_id +JOIN dds.dim_date d ON f.date_key = d.date_key +WHERE ct.city_name = 'Москва' + AND d.year = 2024 AND d.month = 1; +``` + +Запрос читаемый, но JOIN-ов уже три - и это для простого вопроса. В реальном ядре цепочка может быть длиннее: `Клиент → Город → Регион → Страна`. + **Плюсы:** * Удобно поддерживать **единую «карту бизнеса»**: где живут «клиент», «заказ», «договор» и как они связаны; @@ -319,22 +366,36 @@ AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid --- -### 2. Звезда (Star Schema) +### 2. Звезда (Star Schema) *Источник: Ральф Кимболл (Ralph Kimball)* -Самый распространённый способ построения таблиц для слоя витрин. +Самый распространённый способ построения таблиц для слоя витрин. Именно эту модель мы использовали в [разделе 5](#5-базовые-понятия-факты-измерения-scd): схема `fact_sales` + `dim_date` / `dim_customer` / `dim_product` - это и есть Звезда. Структура: * в центре - таблица фактов (события и метрики); * вокруг - измерения, обычно денормализованные («плоские») - широкие таблицы со всеми атрибутами сущности. Мы сознательно избегаем цепочек справочников ради простоты запросов. -Пример: `fact_sales` + `dim_date`, `dim_customer`, `dim_product`. - ![Звёздная схема вокруг fact_sales](images/star-model-small.jpg) Методология Ральфа Кимбалла (Ralph Kimball) как раз делает упор на такие звёздные схемы: витрины, которые максимально просты для чтения и понятны аналитикам и BI-инструментам. +#### Тот же вопрос - в Звезде + +В Звезде `city` лежит прямо в `dim_customer` (денормализовано). Тот же отчёт выглядит проще: + +```sql +-- Звезда: два JOIN, город - прямо в измерении +SELECT SUM(f.amount) +FROM dds.fact_sales f +JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk +JOIN dds.dim_date d ON f.date_key = d.date_key +WHERE c.city = 'Москва' + AND d.year = 2024 AND d.month = 1; +``` + +На один JOIN меньше, и не нужно знать, где именно хранится город: он лежит прямо в карточке клиента. Для аналитика или BI-инструмента это большая разница. + **Плюсы:** * проста для понимания: аналитикам и BI-инструментам удобно работать с такой моделью; @@ -343,7 +404,7 @@ AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid **Минусы:** -* измерения денормализованы, поэтому атрибуты дублируются (например, регион повторяется у всех клиентов региона); +* измерения денормализованы, поэтому атрибуты дублируются (например, название города повторяется у всех клиентов из этого города); * изменения атрибутов могут требовать обновлять много строк в измерении. From f830d54cd74f214214279e3ef7bffc684a6b85ff Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 14:49:49 +0300 Subject: [PATCH 5/8] =?UTF-8?q?refactor(sql):=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=BE=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BE=D0=BC=D0=B0=D1=88=D0=BA=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - решение домашки должно быть самодостаточным и наглядным для самопроверки - Что: - добавлены контрольные SELECT после каждого блока (ODS, DDS, инкремент, DM) - блок 3 (инкремент) стал самодостаточным: загрузка в STG + UPSERT в ODS + SCD2 - DDL витрины вынесен из решения/шаблона/домашки в 07_ddl_hw_customer_status.sql - предусловия в домашке дополнены (05_ddl_dm.sql, пояснение про dim_date) - в шапку решения добавлено напоминание сначала попробовать самостоятельно - Проверка: - визуальная проверка diff Co-Authored-By: Claude Opus 4.6 --- .../Homework_Customer_Status_DDS_DM.md | 20 +-- .../sql/07_ddl_hw_customer_status.sql | 8 ++ .../08_dml_hw_customer_status_template.sql | 7 +- .../09_dml_hw_customer_status_solution.sql | 117 +++++++++++++----- 4 files changed, 96 insertions(+), 56 deletions(-) diff --git a/dwh-modeling/Homework_Customer_Status_DDS_DM.md b/dwh-modeling/Homework_Customer_Status_DDS_DM.md index 41bc004..93af1ef 100644 --- a/dwh-modeling/Homework_Customer_Status_DDS_DM.md +++ b/dwh-modeling/Homework_Customer_Status_DDS_DM.md @@ -62,11 +62,12 @@ customer_id,status,event_ts,_load_id,_load_ts 1. Поднимите demo‑Postgres по инструкции из корневого `README.md`. 2. Выполните базовые скрипты DWH: - `01_ddl_stg-dds.sql` - - `02_dml_stg-dds.sql` + - `02_dml_stg-dds.sql` (нужен как минимум для `dds.dim_date`) + - `05_ddl_dm.sql` (создаёт схему `dm` для витрин) 3. Выполните DDL для домашки: - `07_ddl_hw_customer_status.sql` -После этого схемы `stg`, `ods`, `dds` уже существуют, а дополнительные таблицы для статусов созданы. +После этого схемы `stg`, `ods`, `dds`, `dm` уже существуют, а дополнительные таблицы для статусов созданы. --- @@ -242,20 +243,7 @@ cat dwh-modeling/data/customer_status_events_increment.csv | ./postgres-bookings Опциональное задание для закрепления: собрать небольшую витрину с количеством клиентов по статусам на каждую дату. -Перед началом убедитесь, что слой DM создан (схема `dm` и таблицы): - -- выполните `dwh-modeling/sql/05_ddl_dm.sql` (один раз); -- затем можно собирать витрину. - -Пример целевой таблицы: - -```sql -CREATE TABLE dm.mart_customer_status_daily ( - date_actual DATE NOT NULL, - status VARCHAR(20) NOT NULL, - customers_cnt INT NOT NULL -); -``` +DDL витрины уже создан в `07_ddl_hw_customer_status.sql` (таблица `dm.mart_customer_status_daily`). Идея: diff --git a/dwh-modeling/sql/07_ddl_hw_customer_status.sql b/dwh-modeling/sql/07_ddl_hw_customer_status.sql index e38b444..63e43ef 100644 --- a/dwh-modeling/sql/07_ddl_hw_customer_status.sql +++ b/dwh-modeling/sql/07_ddl_hw_customer_status.sql @@ -46,3 +46,11 @@ ALTER TABLE dds.dim_customer_status CREATE INDEX ix_dim_customer_status_bk_current ON dds.dim_customer_status (customer_bk) WHERE valid_to IS NULL; + +-- 4. DM: витрина статусов клиентов по датам (опциональная часть домашки) +DROP TABLE IF EXISTS dm.mart_customer_status_daily; +CREATE TABLE dm.mart_customer_status_daily ( + date_actual DATE NOT NULL, + status VARCHAR(20) NOT NULL, + customers_cnt INT NOT NULL +); diff --git a/dwh-modeling/sql/08_dml_hw_customer_status_template.sql b/dwh-modeling/sql/08_dml_hw_customer_status_template.sql index 8dc7b01..97647dc 100644 --- a/dwh-modeling/sql/08_dml_hw_customer_status_template.sql +++ b/dwh-modeling/sql/08_dml_hw_customer_status_template.sql @@ -38,11 +38,6 @@ -- Можно ориентироваться на примеры в 03_demo_increment.sql. -- 4. DM: витрина статусов клиентов по датам (по желанию) --- Пример целевой структуры: --- CREATE TABLE dm.mart_customer_status_daily ( --- date_actual DATE NOT NULL, --- status VARCHAR(20) NOT NULL, --- customers_cnt INT NOT NULL --- ); +-- DDL витрины уже создан в 07_ddl_hw_customer_status.sql (dm.mart_customer_status_daily). -- Идея: на каждую дату взять актуальный статус клиента -- через JOIN dds.dim_customer_status + dds.dim_date. diff --git a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql index 37c3661..8870ca2 100644 --- a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql +++ b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql @@ -2,15 +2,19 @@ -- 09_dml_hw_customer_status_solution.sql -- Решение домашки: статусы клиента (STG -> ODS -> DDS SCD2 -> DM) -- +-- Это ЭТАЛОННОЕ РЕШЕНИЕ. Если вы ещё не пробовали решить домашку сами - +-- вернитесь к заданию (Homework_Customer_Status_DDS_DM.md) и шаблону (08_dml_hw_customer_status_template.sql). +-- Основная ценность задания - в самостоятельном разборе. +-- -- Что делает этот файл: -- 1) Перекладывает события статусов в ODS (приводит типы, чистит пустое). -- 2) Строит DDS-измерение со "встроенной историей" (SCD2): периоды valid_from/valid_to. --- 3) Показывает пример обновления DDS маленькой порцией (инкремент): закрыть старое + вставить новое. +-- 3) Загружает инкрементальную порцию событий и обновляет ODS + DDS. -- 4) Собирает простую витрину в DM: сколько клиентов в каком статусе по дням. -- -- Как запускать: -- - для первого знакомства можно запускать файл целиком; --- - если хотите потренировать инкремент (п.3): добавьте новые события -> обновите ODS -> запустите блок 3 ещё раз. +-- - если хотите потренировать инкремент (п.3): добавьте свои события -> запустите блок 3 ещё раз. -- -- Важно: -- - здесь часто используется TRUNCATE (полная очистка), чтобы было легко повторять домашку; @@ -18,9 +22,10 @@ -- -- Предусловия (DDL + данные в STG): -- 1) dwh-modeling/sql/01_ddl_stg-dds.sql --- 2) dwh-modeling/sql/05_ddl_dm.sql --- 3) dwh-modeling/sql/07_ddl_hw_customer_status.sql --- 4) stg.customer_status_raw заполнена (см. dwh-modeling/Homework_Customer_Status_DDS_DM.md) +-- 2) dwh-modeling/sql/02_dml_stg-dds.sql (нужен dim_date) +-- 3) dwh-modeling/sql/05_ddl_dm.sql +-- 4) dwh-modeling/sql/07_ddl_hw_customer_status.sql +-- 5) stg.customer_status_raw заполнена (см. dwh-modeling/Homework_Customer_Status_DDS_DM.md) -- =============================================== -- ========================================================== @@ -48,6 +53,10 @@ WHERE s.customer_id ~ '^\d+$' AND NULLIF(trim(s.event_ts), '') IS NOT NULL AND NULLIF(trim(s.status), '') IS NOT NULL; +-- Проверка: что получилось в ODS +SELECT 'ods.customer_status count = ' || COUNT(*) FROM ods.customer_status; +SELECT * FROM ods.customer_status ORDER BY customer_id, event_ts; + -- ========================================================== -- 2) DDS: начальная загрузка SCD2 (full refresh) -- ========================================================== @@ -116,29 +125,62 @@ SELECT FROM framed ORDER BY customer_bk, valid_from; +-- Проверка: периоды в DDS (у клиента 101 должно быть 4 строки: new -> active -> vip -> churned) +SELECT 'dim_customer_status count = ' || COUNT(*) FROM dds.dim_customer_status; +SELECT * FROM dds.dim_customer_status ORDER BY customer_bk, valid_from; + -- ========================================================== --- 3) DDS: инкрементальная загрузка SCD2 (по последним событиям) +-- 3) Инкрементальная загрузка: STG -> ODS -> DDS -- ========================================================== --- Этот блок нужен, чтобы показать "как это обычно обновляют": --- после новой порции событий мы: +-- Имитируем приход новой порции событий (customer_status_events_increment.csv): +-- - клиент 101: churned -> active (вернулся) +-- - клиент 102: churned -> active +-- - клиент 103: new -> active +-- - клиент 104: новый клиент, статус new + +-- 3.0) Новые события в STG +INSERT INTO stg.customer_status_raw (customer_id, status, event_ts, _load_id, _load_ts) VALUES +('101','active','2024-11-15 09:00:00','batch_20241115_1000','2024-11-15 10:00:00'), +('102','active','2024-05-05 09:30:00','batch_20240505_1000','2024-05-05 10:00:00'), +('103','active','2024-03-20 12:00:00','batch_20240320_1300','2024-03-20 13:00:00'), +('104','new', '2024-06-01 08:00:00','batch_20240601_0900','2024-06-01 09:00:00'); + +-- 3.1) UPSERT в ODS: добавляем новые события (не трогаем старые) +-- PK в ods.customer_status = (customer_id, event_ts), поэтому каждое уникальное +-- событие встаёт отдельной строкой. Дубли (одинаковый customer_id + event_ts) игнорируем. +INSERT INTO ods.customer_status ( + customer_id, status, event_ts, _load_id, _load_ts +) +SELECT + s.customer_id::INT, + NULLIF(trim(s.status), ''), + NULLIF(trim(s.event_ts), '')::TIMESTAMP, + s._load_id, + COALESCE(s._load_ts, now()) +FROM stg.customer_status_raw s +WHERE s.customer_id ~ '^\d+$' + AND NULLIF(trim(s.event_ts), '') IS NOT NULL + AND NULLIF(trim(s.status), '') IS NOT NULL +ON CONFLICT (customer_id, event_ts) DO NOTHING; + +-- Проверка: в ODS должны появиться новые строки +SELECT 'ods.customer_status after increment = ' || COUNT(*) FROM ods.customer_status; + +-- 3.2) Инкрементальное обновление DDS (SCD2) +-- Идея: -- 1) берём по каждому клиенту самое позднее событие из ODS; --- 2) сравниваем его с текущей версией в DDS (valid_to IS NULL); --- 3) если статус изменился — закрываем старую версию и вставляем новую. +-- 2) сравниваем с текущей версией в DDS (valid_to IS NULL); +-- 3) если статус изменился - закрываем старую версию и вставляем новую. -- --- Ограничение учебного варианта (в домашке можно игнорировать): --- - если вы добавили событие "задним числом" со старой датой, этот блок не пересоберёт всю историю. --- Для такого кейса обычно делают отдельную логику или full refresh. --- --- Примечание: --- - в этом файле блок 2 (full refresh) запускается раньше, поэтому сразу после него --- блок 3, скорее всего, ничего не изменит. Зато его можно повторять после новых событий. +-- Ограничение учебного варианта: +-- - если добавили событие "задним числом" со старой датой, этот блок не пересоберёт всю историю. +-- Для такого кейса обычно делают full refresh (блок 2). BEGIN; - -- 3.1) Закрываем предыдущую актуальную версию + -- 3.2a) Закрываем предыдущую актуальную версию WITH ranked AS ( - -- ranked: выбираем "самое свежее" событие на клиента. - -- Если event_ts одинаковый, берём то, что загрузилось позже (_load_ts). + -- ranked: выбираем "самое свежее" событие на клиента SELECT customer_id AS customer_bk, status, @@ -155,8 +197,8 @@ BEGIN; -- delta: ровно одна строка на клиента (самое свежее событие) SELECT * FROM ranked WHERE rn = 1 ), - current AS ( - -- current: текущие версии в DDS (valid_to IS NULL) + current_ver AS ( + -- current_ver: текущие версии в DDS (valid_to IS NULL) SELECT d.* FROM dds.dim_customer_status d WHERE d.valid_to IS NULL @@ -172,7 +214,7 @@ BEGIN; t.eff_date, c.customer_status_sk FROM delta t - JOIN current c + JOIN current_ver c ON c.customer_bk = t.customer_bk WHERE c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from -- не создаём период нулевой/отрицательной длины @@ -180,9 +222,9 @@ BEGIN; WHERE d.customer_status_sk = x.customer_status_sk AND d.valid_to IS NULL; - -- 3.2) Вставляем новую версию + -- 3.2b) Вставляем новую версию WITH ranked AS ( - -- ranked/delta/current повторяем отдельно, чтобы блок INSERT читался отдельно от UPDATE + -- ranked/delta/current_ver повторяем отдельно, чтобы блок INSERT читался отдельно от UPDATE SELECT customer_id AS customer_bk, status, @@ -198,14 +240,14 @@ BEGIN; delta AS ( SELECT * FROM ranked WHERE rn = 1 ), - current AS ( + current_ver AS ( SELECT d.* FROM dds.dim_customer_status d WHERE d.valid_to IS NULL ), to_insert AS ( -- to_insert: кого "вставляем": - -- 1) новый клиент (в current нет строки); + -- 1) новый клиент (в current_ver нет строки); -- 2) изменившийся клиент (статус поменялся). SELECT t.customer_bk, @@ -213,7 +255,7 @@ BEGIN; t.hashdiff, t.eff_date FROM delta t - LEFT JOIN current c + LEFT JOIN current_ver c ON c.customer_bk = t.customer_bk WHERE c.customer_status_sk IS NULL OR (c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from) @@ -237,6 +279,11 @@ BEGIN; ); COMMIT; +-- Проверка: у клиента 101 должна появиться 5-я строка (active с 2024-11-15), +-- у 104 - первая строка (new с 2024-06-01) +SELECT 'dim_customer_status after increment = ' || COUNT(*) FROM dds.dim_customer_status; +SELECT * FROM dds.dim_customer_status ORDER BY customer_bk, valid_from; + -- ========================================================== -- 4) DM: витрина статусов клиентов по датам (full refresh) -- ========================================================== @@ -244,12 +291,7 @@ COMMIT; -- Витрина "снимок на дату": -- для каждого дня считаем, сколько клиентов было в каждом статусе. -- Берём календарь dds.dim_date и подбираем статус по периоду valid_from/valid_to. - -CREATE TABLE IF NOT EXISTS dm.mart_customer_status_daily ( - date_actual DATE NOT NULL, - status VARCHAR(20) NOT NULL, - customers_cnt INT NOT NULL -); +-- DDL витрины - в 07_ddl_hw_customer_status.sql. TRUNCATE dm.mart_customer_status_daily; @@ -274,3 +316,10 @@ JOIN dds.dim_customer_status s AND (s.valid_to IS NULL OR d.date_actual < s.valid_to) GROUP BY d.date_actual, s.status ORDER BY d.date_actual, s.status; + +-- Проверка: выборочные даты из витрины +SELECT 'mart_customer_status_daily count = ' || COUNT(*) FROM dm.mart_customer_status_daily; +SELECT * +FROM dm.mart_customer_status_daily +WHERE date_actual IN ('2024-01-15', '2024-04-10', '2024-09-15', '2024-12-01') +ORDER BY date_actual, status; From 14a61a3b46779b2752d86e55813e5424d60b4f8c Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 14:55:38 +0300 Subject: [PATCH 6/8] =?UTF-8?q?docs(modeling):=20=D0=BF=D0=BE=D1=8F=D1=81?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B7=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D1=81=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=BA=D0=BE=D0=B2=D1=8B=D0=BC=20=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=D0=BD=D1=8B=D0=BC=20ODS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - менти видит два разных паттерна ODS (снимок vs event log) и не понимает почему - Что: - в решении домашки (блок 1 ODS): комментарий, почему customer_status хранит все события - в домашке (раздел 3.2): пометка о сознательном выборе модели ODS - Проверка: - визуальная проверка diff Co-Authored-By: Claude Opus 4.6 --- dwh-modeling/Homework_Customer_Status_DDS_DM.md | 2 ++ dwh-modeling/sql/09_dml_hw_customer_status_solution.sql | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/dwh-modeling/Homework_Customer_Status_DDS_DM.md b/dwh-modeling/Homework_Customer_Status_DDS_DM.md index 93af1ef..81a3f74 100644 --- a/dwh-modeling/Homework_Customer_Status_DDS_DM.md +++ b/dwh-modeling/Homework_Customer_Status_DDS_DM.md @@ -122,6 +122,8 @@ SELECT * FROM stg.customer_status_raw LIMIT 10; ### 3.2. ODS: очистка и типизация +> 💡 Обратите внимание: в основном примере `ods.customers` хранит **снимок** (одна строка на клиента, PK = `customer_id`), а здесь `ods.customer_status` хранит **все события** (PK = `customer_id + event_ts`). Это не ошибка, а сознательный выбор: источник данных о статусах - поток событий, и ODS сохраняет эту природу. Подробнее - в комментариях к решению. + В файле `08_dml_hw_customer_status_template.sql` найдите заготовку блока ODS и допишите SQL: - привести: diff --git a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql index 8870ca2..cf22a73 100644 --- a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql +++ b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql @@ -36,6 +36,13 @@ -- - STG хранит "как пришло" (обычно TEXT); -- - ODS хранит "аккуратно": правильные типы + простая чистка. -- Для простоты пересобираем ODS с нуля. +-- +-- Обратите внимание: ods.customer_status хранит ВСЕ события (PK = customer_id + event_ts), +-- а не только последнее состояние, как ods.customers (PK = customer_id). +-- Причина: источник данных здесь - поток событий ("статус стал X в момент Y"), +-- а не снимок ("вот текущие данные клиента"). ODS сохраняет природу источника: +-- снимок остаётся снимком, события остаются событиями. +-- Благодаря этому full backfill SCD2 (блок 2) строится прямо из ODS, а не из STG. TRUNCATE ods.customer_status; From c6a20f3cf72f0a4a7fe2e776d125ac05298368a0 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 15:13:38 +0300 Subject: [PATCH 7/8] =?UTF-8?q?fix(modeling):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D0=B0=D1=82=D0=B0=D0=BC=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=80=D1=91=D1=81=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - в статье и скриптах обнаружены фактические ошибки и несостыковки - Что: - README: f.order_date -> d.date_actual в примере витрины (колонки order_date нет в fact_sales) - README: «календарь на 10 лет» -> «5 лет» (соответствует генерации 2023-2027 в скрипте) - 09_solution: bounds CTE теперь использует CURRENT_DATE для открытых интервалов (valid_to IS NULL) - 09_solution: комментарий про дубли в STG при повторном запуске блока 3 - 05_ddl_dm: выравнивание total_line_items - AGENTS.md: обновлён диапазон скриптов 01-09 - Проверка: - визуальная проверка diff Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 2 +- dwh-modeling/README.md | 6 +++--- dwh-modeling/sql/05_ddl_dm.sql | 2 +- dwh-modeling/sql/09_dml_hw_customer_status_solution.sql | 8 +++++++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec1f513..6ce2411 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization - Root `README.md` describes the learning roadmap (RU). -- `dwh-modeling/` contains the article and demo DWH model; SQL lives in `dwh-modeling/sql` as ordered scripts `01_...sql`–`06_...sql`. +- `dwh-modeling/` contains the article and demo DWH model; SQL lives in `dwh-modeling/sql` as ordered scripts `01_...sql`–`09_...sql` (07–09 are homework DDL, template and solution). - `postgres-bookings/` is a Dockerized PostgreSQL + demo “bookings” DB; start it first, then apply DWH scripts against the `demo` database. ## Build, Test, and Development Commands diff --git a/dwh-modeling/README.md b/dwh-modeling/README.md index fa80267..a4d038e 100644 --- a/dwh-modeling/README.md +++ b/dwh-modeling/README.md @@ -180,7 +180,7 @@ flowchart TD |---------|------------| | `dds.dim_customer` | Измерение «Клиент» с историей (SCD Type 2) | | `dds.dim_product` | Измерение «Товар» | -| `dds.dim_date` | Готовый календарь на 10 лет вперёд (день/неделя/месяц/квартал) | +| `dds.dim_date` | Готовый календарь на 5 лет вперёд (день/неделя/месяц/квартал) | | `dds.fact_sales` | Факт «Продажа» — строка заказа с суммой и количеством | 💡 **Суррогатный ключ (Surrogate Key, SK)** — это `BIGINT`, который мы генерируем сами (например, `customer_sk = 1001`). @@ -558,8 +558,8 @@ 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 >= c.valid_from - AND (c.valid_to IS NULL OR f.order_date < c.valid_to) -- SCD! + AND d.date_actual >= c.valid_from + AND (c.valid_to IS NULL OR d.date_actual < c.valid_to) -- SCD! GROUP BY d.date_actual, p.product_name, c.customer_segment; ``` diff --git a/dwh-modeling/sql/05_ddl_dm.sql b/dwh-modeling/sql/05_ddl_dm.sql index 214bb46..4b02c46 100644 --- a/dwh-modeling/sql/05_ddl_dm.sql +++ b/dwh-modeling/sql/05_ddl_dm.sql @@ -21,7 +21,7 @@ CREATE TABLE dm.mart_customer_360 ( customer_bk INT NOT NULL, first_order_date DATE, last_order_date DATE, - total_line_items INT NOT NULL, + total_line_items INT NOT NULL, total_items INT NOT NULL, lifetime_value NUMERIC(18,2) NOT NULL, last_email VARCHAR(100), diff --git a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql index cf22a73..42686d8 100644 --- a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql +++ b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql @@ -147,6 +147,10 @@ SELECT * FROM dds.dim_customer_status ORDER BY customer_bk, valid_from; -- - клиент 104: новый клиент, статус new -- 3.0) Новые события в STG +-- При повторном запуске эти строки добавятся в STG ещё раз (дубли). +-- Для демо это не страшно: ODS-вставка ниже использует ON CONFLICT DO NOTHING, +-- а SCD2-блок защищён от повторных вставок через NOT EXISTS. +-- В продакшене STG обычно очищается перед каждой загрузкой (TRUNCATE / партиция по дате). INSERT INTO stg.customer_status_raw (customer_id, status, event_ts, _load_id, _load_ts) VALUES ('101','active','2024-11-15 09:00:00','batch_20241115_1000','2024-11-15 10:00:00'), ('102','active','2024-05-05 09:30:00','batch_20240505_1000','2024-05-05 10:00:00'), @@ -305,7 +309,9 @@ TRUNCATE dm.mart_customer_status_daily; WITH bounds AS ( SELECT min(valid_from) AS date_from, - max(coalesce(valid_to, valid_from)) AS date_to + -- CURRENT_DATE для открытых интервалов (valid_to IS NULL = текущий статус), + -- иначе витрина не покроет даты после последней смены статуса. + max(coalesce(valid_to, CURRENT_DATE)) AS date_to FROM dds.dim_customer_status ) INSERT INTO dm.mart_customer_status_daily ( From b3826f47cbb56906303ce30d24c56801ccc2be49 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 21 Feb 2026 19:21:39 +0300 Subject: [PATCH 8/8] =?UTF-8?q?fix(modeling):=20=D0=BF=D1=80=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B2=20README=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D1=91?= =?UTF-8?q?=D0=BD=20=D0=B2=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B8=D0=B5=20=D1=81=D0=BE=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D0=BF=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - customer_segment: убрана несуществующая колонка dim_customer, заменена на CASE по сумме (как в 06_dml_dm.sql) - MATERIALIZED VIEW заменён на TRUNCATE + INSERT INTO (как в скриптах), упоминание MV оставлено в ремарке - добавлен комментарий о недетерминированности CURRENT_DATE в bounds Co-Authored-By: Claude Opus 4.6 --- dwh-modeling/README.md | 21 ++++++++++++------- .../09_dml_hw_customer_status_solution.sql | 2 ++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/dwh-modeling/README.md b/dwh-modeling/README.md index a4d038e..b28d9cc 100644 --- a/dwh-modeling/README.md +++ b/dwh-modeling/README.md @@ -544,11 +544,17 @@ flowchart TD ```sql -- mart_daily_sales: ежедневные продажи с сегментацией -CREATE MATERIALIZED VIEW dm.mart_daily_sales AS +-- Полная пересборка (full refresh) - для простоты; в продакшене бывает incremental. +TRUNCATE dm.mart_daily_sales; + +INSERT INTO dm.mart_daily_sales ( + date_actual, product_name, customer_segment, total_qty, total_revenue +) SELECT - d.date_actual AS order_date, + d.date_actual, p.product_name, - c.customer_segment, -- например: 'Premium', 'Basic' + -- Сегмент определяем по сумме строки (в реальности может быть атрибутом клиента) + CASE WHEN f.amount >= 200 THEN 'Premium' ELSE 'Basic' END AS customer_segment, SUM(f.quantity) AS total_qty, SUM(f.amount) AS total_revenue FROM dds.fact_sales f @@ -557,13 +563,12 @@ JOIN dds.dim_date d 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 d.date_actual >= c.valid_from - AND (c.valid_to IS NULL OR d.date_actual < c.valid_to) -- SCD! -GROUP BY d.date_actual, p.product_name, c.customer_segment; + ON f.customer_sk = c.customer_sk -- факт ссылается на нужную версию SK +GROUP BY d.date_actual, p.product_name, + CASE WHEN f.amount >= 200 THEN 'Premium' ELSE 'Basic' END; ``` -> 💡 **Материализованное представление (MATERIALIZED VIEW)** — это «кэш» результата. Обновляется по расписанию (например, ночью). +> 💡 В продакшене витрину иногда оформляют как **MATERIALIZED VIEW** - «кэш» результата запроса, который обновляется по расписанию. В нашем примере используем обычную таблицу с `TRUNCATE` + `INSERT` - для учебных целей это нагляднее. ✏️ **Попробуйте сами:** [Домашка: статусы клиента от STG до DDS (и немного DM)](Homework_Customer_Status_DDS_DM.md) — пройдёте тот же путь, но самостоятельно. diff --git a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql index 42686d8..caba2a0 100644 --- a/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql +++ b/dwh-modeling/sql/09_dml_hw_customer_status_solution.sql @@ -311,6 +311,8 @@ WITH bounds AS ( min(valid_from) AS date_from, -- CURRENT_DATE для открытых интервалов (valid_to IS NULL = текущий статус), -- иначе витрина не покроет даты после последней смены статуса. + -- Нюанс: количество строк в витрине зависит от даты запуска (каждый + -- день добавляется ещё один день). Для учебных целей это приемлемо. max(coalesce(valid_to, CURRENT_DATE)) AS date_to FROM dds.dim_customer_status )