diff --git a/TODO.md b/TODO.md index fb12cb2..1fa6d1d 100644 --- a/TODO.md +++ b/TODO.md @@ -52,3 +52,9 @@ - при необходимости сослаться на соответствующие лицензии/README исходных проектов. - [x] Добавить в образ Airflow установку `psql`, чтобы тестировать загрузку CSV из CLI внутри контейнера (без root и дополнительных зависимостей на хосте). + +- [ ] Денормализовать `dds.dim_routes` (добавить departure_city, arrival_city, airplane_model, total_seats): + - привести измерение в соответствие с принципом Кимбалла («самодостаточное измерение»); + - упростить `dm.route_performance` с 4-JOIN до 1-JOIN; + - обновить DAG-зависимости: airports+airplanes DQ → routes load; + - подробный план: `docs/internal/dim_routes_denormalization_plan.md`. diff --git a/docs/internal/dim_routes_denormalization_plan.md b/docs/internal/dim_routes_denormalization_plan.md new file mode 100644 index 0000000..48070d2 --- /dev/null +++ b/docs/internal/dim_routes_denormalization_plan.md @@ -0,0 +1,205 @@ +# План: Денормализация dim_routes + упрощение route_performance + +> **Статус:** запланировано, не реализовано. + +## Контекст + +`dds.dim_routes` хранит только FK-ссылки (`departure_airport`, `arrival_airport`, `airplane_code`) без атрибутов (город, модель самолёта, кол-во мест). Из-за этого витрина `dm.route_performance` вынуждена делать 4-JOIN цепочку вместо одного JOIN к измерению. Это противоречит принципу Кимбалла (измерение должно быть «самодостаточным») и усложняет учебный материал. + +**Цель:** добавить в dim_routes 4 денормализованных колонки, упростить route_performance до 1 JOIN, показать студентам правильную Star Schema. + +--- + +## Файлы для изменения + +| # | Файл | Суть | +|---|---|---| +| 1 | `sql/dds/dim_routes_ddl.sql` | +4 колонки через ALTER TABLE | +| 2 | `sql/dds/dim_routes_load.sql` | JOIN к airports/airplanes при INSERT + refresh-шаг | +| 3 | `sql/dds/dim_routes_dq.sql` | NOT NULL проверка для новых колонок (current-срез) | +| 4 | `airflow/dags/bookings_to_gp_dds.py` | Зависимость: airports+airplanes DQ → routes load | +| 5 | `sql/dm/route_performance_load.sql` | Упрощение 4-JOIN → 1-JOIN | +| 6 | `tests/test_dags_smoke.py` | Новые assert для зависимости routes от airports/airplanes | +| 7 | `docs/internal/db_schema.md` | Обновить схему dim_routes | + +--- + +## Шаг 1. DDL — `sql/dds/dim_routes_ddl.sql` + +После существующего `COMMENT ON TABLE` добавить учебный комментарий и 4 ALTER TABLE: + +```sql +-- Денормализация атрибутов из SCD1-измерений (dim_airports, dim_airplanes). +-- +-- Учебный комментарий (Kimball Star Schema): +-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes — +-- и аналитик видит маршрут, города, модель самолёта и кол-во мест. +-- +-- Эти колонки НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения +-- атрибутов маршрута (аэропорт, самолёт, расписание). Если изменится +-- название города — обновим отдельным refresh-шагом, не создавая новую версию. +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS departure_city TEXT; +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS arrival_city TEXT; +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS airplane_model TEXT; +ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS total_seats INTEGER; +``` + +Колонки nullable — `ALTER TABLE ADD COLUMN ... NOT NULL` без DEFAULT упадёт на существующих строках. DQ проверит NOT NULL для current-среза. + +--- + +## Шаг 2. Load — `sql/dds/dim_routes_load.sql` + +Структура: 3 фазы вместо 2. + +**Фаза 1 (без изменений):** temp table + hashdiff из ods.routes, UPDATE закрытие версий. Hashdiff **не включает** денормализованные атрибуты. + +**Фаза 2 (изменён INSERT):** при вставке новой версии — LEFT JOIN к dim_airports (×2) и dim_airplanes для заполнения departure_city, arrival_city, airplane_model, total_seats. + +Конкретно: в INSERT (Statement 2, строки 59-107) добавить: +- В список колонок: `departure_city, arrival_city, airplane_model, total_seats` +- В SELECT: `dep.city, arr.city, air.model, air.total_seats` +- LEFT JOIN к dim_airports и dim_airplanes (LEFT — чтобы не терять маршруты при отсутствии аэропорта; DQ поймает) + +**Фаза 3 (новая):** refresh денормализованных атрибутов для всех current-версий: + +```sql +-- Фаза 3: Обновление денормализованных атрибутов (refresh). +-- Нужна для SCD1-изменений в dim_airports/dim_airplanes (напр. переименование города). +-- Обновляем ТОЛЬКО current-версии (valid_to IS NULL). +UPDATE dds.dim_routes AS d +SET departure_city = dep.city, + arrival_city = arr.city, + airplane_model = air.model, + total_seats = air.total_seats, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM dds.dim_airports AS dep, + dds.dim_airports AS arr, + dds.dim_airplanes AS air +WHERE d.valid_to IS NULL + AND dep.airport_bk = d.departure_airport + AND arr.airport_bk = d.arrival_airport + AND air.airplane_bk = d.airplane_code + AND ( + d.departure_city IS DISTINCT FROM dep.city + OR d.arrival_city IS DISTINCT FROM arr.city + OR d.airplane_model IS DISTINCT FROM air.model + OR d.total_seats IS DISTINCT FROM air.total_seats + ); +``` + +Этот же шаг при первом запуске заполнит колонки для существующих данных (backfill). + +--- + +## Шаг 3. DQ — `sql/dds/dim_routes_dq.sql` + +Перед финальным `RAISE NOTICE` добавить проверку: + +```sql +-- Денормализованные поля не пустые в текущих (актуальных) версиях. +-- Исторические версии могли быть загружены ДО добавления колонок — пропускаем. +SELECT COUNT(*) INTO v_null_count +FROM dds.dim_routes +WHERE valid_to IS NULL + AND (departure_city IS NULL OR arrival_city IS NULL + OR airplane_model IS NULL OR total_seats IS NULL); + +IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в current-срезе dim_routes найдены NULL денормализованные поля: %', + v_null_count; +END IF; +``` + +--- + +## Шаг 4. DAG — `airflow/dags/bookings_to_gp_dds.py` + +Убрать `load_dds_dim_routes` из параллельного fan-out, добавить зависимость от DQ airports и airplanes: + +```python +# Было: +dq_dds_dim_calendar >> [ + load_dds_dim_airports, + load_dds_dim_airplanes, + load_dds_dim_tariffs, + load_dds_dim_passengers, + load_dds_dim_routes, # параллельно +] + +# Стало: +dq_dds_dim_calendar >> [ + load_dds_dim_airports, + load_dds_dim_airplanes, + load_dds_dim_tariffs, + load_dds_dim_passengers, +] + +# dim_routes зависит от airports и airplanes (денормализация). +[dq_dds_dim_airports, dq_dds_dim_airplanes] >> load_dds_dim_routes +``` + +Транзитивная зависимость от calendar сохраняется через airports/airplanes. + +--- + +## Шаг 5. Витрина — `sql/dm/route_performance_load.sql` + +Упростить Шаг 3. Было 4 JOIN → станет 1 JOIN: + +```sql +FROM tmp_route_metrics m +JOIN dds.dim_routes r_curr + ON m.route_bk = r_curr.route_bk AND r_curr.valid_to IS NULL +``` + +SELECT использует `r_curr.departure_airport AS departure_airport_bk`, `r_curr.departure_city`, `r_curr.airplane_code AS airplane_bk`, `r_curr.airplane_model`, `r_curr.total_seats`. Добавить учебный комментарий: «Благодаря денормализации dim_routes — один JOIN вместо четырёх.» + +--- + +## Шаг 6. Тесты — `tests/test_dags_smoke.py` + +Добавить в `test_bookings_to_gp_dds_dag_structure`: + +```python +# dim_routes зависит от airports и airplanes (денормализация). +_assert_reachable(dag, "dq_dds_dim_airports", "load_dds_dim_routes") +_assert_reachable(dag, "dq_dds_dim_airplanes", "load_dds_dim_routes") +``` + +--- + +## Шаг 7. Документация — `docs/internal/db_schema.md` + +Обновить описание dim_routes: добавить 4 новые колонки, пометить «денормализовано из dim_airports/dim_airplanes». Обновить граф зависимостей DAG. + +--- + +## Что НЕ меняется + +- **fact_flight_sales_load.sql** — факт резолвит SK через свои JOIN-ы. Без изменений. +- **passenger_loyalty, monthly_overview** — используют только `route_bk` из dim_routes. +- **airport_traffic** — вообще не джойнит dim_routes. +- **hashdiff** — остаётся прежним (только атрибуты из ods.routes). + +--- + +## Верификация + +1. `make ddl-gp` — накатить DDL (ALTER TABLE добавит колонки) +2. Запустить DAG `bookings_to_gp_dds` — проверить: + - airports/airplanes грузятся ДО routes (Graph view) + - Фаза 3 (refresh) заполняет departure_city, arrival_city, airplane_model, total_seats + - DQ проходит без ошибок +3. Запустить DAG `bookings_to_gp_dm` — проверить: + - route_performance загружается с 1 JOIN + - Витрина содержит корректные города и модели самолётов +4. `make test` — smoke-тесты DAG проходят (включая новые assert'ы) +5. SQL-проверка: + ```sql + SELECT route_bk, departure_city, arrival_city, airplane_model, total_seats + FROM dds.dim_routes WHERE valid_to IS NULL LIMIT 5; + ```