feat(validate): добавлен валидационный DAG bookings_validate

- Зачем:
  - студент реализует 9 объектов DWH без автоматической обратной связи;
  - ошибки (NULL в PK, дубли BK, сломанный SCD2) обнаруживались только на слое DM
    через 2–3 слоя, где отладка многократно сложнее.
- Что:
  - создан DAG bookings_validate.py — три параллельных TaskGroup (validate_ods,
    validate_dds, validate_dm), schedule=None (только ручной trigger);
  - создано 17 SQL-скриптов в sql/validate/: ODS BK coverage vs STG-батч,
    согласованность _load_id между ods.airplanes и ods.seats, дубли BK, NULL в PK,
    покрытие ODS→DDS для измерений (dim_routes требует открытой версии valid_to IS NULL),
    SCD2 активный тест backup→mutate→student_load→check(5 инвариантов)→restore
    (trigger_rule=all_done), проверка «дыр» между SCD2-версиями,
    exists-чеки для 4 DM-витрин;
  - добавлен класс TestBookingsValidate (5 smoke-тестов) в tests/test_dags_smoke.py.
- Проверка:
  - make test — 4 passed, 14 skipped (DAG-тесты скипаются без Airflow, норма);
  - ручной trigger bookings_validate в Airflow UI на solution-ветке — все таски зелёные.
This commit is contained in:
2026-03-12 12:12:44 +03:00
parent 23cde47fe7
commit a51e2a6243
19 changed files with 850 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
"""
Валидационный DAG: самопроверка студенческих заданий.
Запускается вручную в Airflow UI после реализации заданий.
Таски сгруппированы по слоям — студент видит, где именно проблема.
Структура:
validate_ods — проверки ODS: rowcount, дубли BK, NULL в PK
validate_dds — проверки DDS: SCD1-измерения, SCD2 dim_routes (активный тест!)
validate_dm — проверки DM-витрин: не пусты, нет NULL в ключах
Все три группы запускаются параллельно — инкрементальная обратная связь:
студент может проверить только ODS, пока DDS/DM ещё не реализованы.
"""
from datetime import timedelta
from logging import getLogger
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.utils.task_group import TaskGroup
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
log = getLogger(__name__)
default_args = {
"owner": "airflow",
"retries": 0, # Без ретраев — студент должен увидеть ошибку сразу
"retry_delay": timedelta(seconds=10),
}
with DAG(
dag_id="bookings_validate",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None, # Только ручной запуск
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "validate"],
description="Валидация студенческих заданий: ODS, DDS, DM",
) as dag:
with TaskGroup("validate_ods") as validate_ods:
check_ods_airplanes = PostgresOperator(
task_id="check_ods_airplanes_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_airplanes_rowcount.sql",
)
check_ods_seats = PostgresOperator(
task_id="check_ods_seats_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_seats_rowcount.sql",
)
check_ods_dup_bk = PostgresOperator(
task_id="check_ods_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_dup_bk.sql",
)
check_ods_pks = PostgresOperator(
task_id="check_ods_no_null_pks",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_null_pks.sql",
)
with TaskGroup("validate_dds") as validate_dds:
check_dim_airplanes = PostgresOperator(
task_id="check_dim_airplanes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_airplanes_exists.sql",
)
check_dim_passengers = PostgresOperator(
task_id="check_dim_passengers_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_exists.sql",
)
check_dim_passengers_dup = PostgresOperator(
task_id="check_dim_passengers_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_no_dup_bk.sql",
)
check_dim_routes = PostgresOperator(
task_id="check_dim_routes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_exists.sql",
)
# SCD2 активный тест: цепочка backup → mutate → load → check → restore
scd2_backup = PostgresOperator(
task_id="scd2_backup",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_backup.sql",
)
scd2_mutate = PostgresOperator(
task_id="scd2_mutate",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_mutate.sql",
)
scd2_run_load = PostgresOperator(
task_id="scd2_run_student_load",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_routes_load.sql", # студенческий load-скрипт!
)
scd2_check = PostgresOperator(
task_id="scd2_check",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_check.sql",
)
scd2_restore = PostgresOperator(
task_id="scd2_restore",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_restore.sql",
trigger_rule="all_done", # Откат ВСЕГДА, даже если check упал
)
check_dim_routes_gaps = PostgresOperator(
task_id="check_dim_routes_no_gaps",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_no_gaps.sql",
)
# SCD2 цепочка
scd2_backup >> scd2_mutate >> scd2_run_load >> scd2_check >> scd2_restore
# no_gaps запускается после restore (на чистых данных)
scd2_restore >> check_dim_routes_gaps
with TaskGroup("validate_dm") as validate_dm:
check_airport_traffic = PostgresOperator(
task_id="check_airport_traffic_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/airport_traffic_exists.sql",
)
check_route_performance = PostgresOperator(
task_id="check_route_performance_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/route_performance_exists.sql",
)
check_monthly_overview = PostgresOperator(
task_id="check_monthly_overview_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/monthly_overview_exists.sql",
)
check_passenger_loyalty = PostgresOperator(
task_id="check_passenger_loyalty_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/passenger_loyalty_exists.sql",
)
+23
View File
@@ -0,0 +1,23 @@
-- Проверка dm.airport_traffic:
-- 1. Таблица не пуста
-- 2. Нет NULL в ключевых полях (traffic_date, airport_sk)
DO $$
DECLARE
v_count BIGINT;
v_null_pk BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dm.airport_traffic;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dm.airport_traffic пуста. Реализуйте загрузку: sql/dm/airport_traffic_load.sql';
END IF;
SELECT COUNT(*) INTO v_null_pk
FROM dm.airport_traffic
WHERE traffic_date IS NULL OR airport_sk IS NULL;
IF v_null_pk > 0 THEN
RAISE EXCEPTION 'FAILED: dm.airport_traffic содержит % строк с NULL в ключе (traffic_date, airport_sk).', v_null_pk;
END IF;
RAISE NOTICE 'PASSED: dm.airport_traffic содержит % строк', v_count;
END $$;
+41
View File
@@ -0,0 +1,41 @@
-- Проверка dds.dim_airplanes:
-- 1. Таблица не пуста
-- 2. Нет дублей по airplane_bk (SCD1 — UPSERT должен это гарантировать)
-- 3. Покрытие ODS: все airplane_code из ods.airplanes есть в измерении
-- 4. total_seats заполнен (вычисляется агрегацией из ods.seats; NULL = потерян JOIN)
DO $$
DECLARE
v_count BIGINT;
v_dup BIGINT;
v_missing BIGINT;
v_null_seats BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dds.dim_airplanes;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dds.dim_airplanes пуста. Реализуйте загрузку: sql/dds/dim_airplanes_load.sql';
END IF;
SELECT COUNT(*) - COUNT(DISTINCT airplane_bk) INTO v_dup FROM dds.dim_airplanes;
IF v_dup > 0 THEN
RAISE EXCEPTION 'FAILED: dds.dim_airplanes содержит % дублей по airplane_bk. Проверьте SCD1-логику (UPSERT).', v_dup;
END IF;
SELECT COUNT(*) INTO v_missing
FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s
WHERE NOT EXISTS (
SELECT 1 FROM dds.dim_airplanes AS d WHERE d.airplane_bk = s.airplane_code
);
IF v_missing > 0 THEN
RAISE EXCEPTION 'FAILED: % самолётов из ods.airplanes отсутствуют в dds.dim_airplanes.', v_missing;
END IF;
-- total_seats вычисляется через JOIN с ods.seats; если JOIN забыт — будет NULL
SELECT COUNT(*) INTO v_null_seats
FROM dds.dim_airplanes WHERE total_seats IS NULL;
IF v_null_seats > 0 THEN
RAISE EXCEPTION E'FAILED: % строк в dds.dim_airplanes имеют NULL в total_seats.\n'
'Подсказка: total_seats считается агрегацией из ods.seats — проверьте JOIN в dim_airplanes_load.sql.', v_null_seats;
END IF;
RAISE NOTICE 'PASSED: dds.dim_airplanes содержит % строк, покрывает все BK из ODS, total_seats заполнен', v_count;
END $$;
+33
View File
@@ -0,0 +1,33 @@
-- Проверка dds.dim_passengers:
-- 1. Таблица не пуста
-- 2. Нет дублей по passenger_id (SCD1 — типичная ошибка: INSERT без EXISTS)
-- 3. Покрытие ODS: все уникальные passenger_id из ods.tickets есть в измерении
DO $$
DECLARE
v_count BIGINT;
v_dup BIGINT;
v_missing BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dds.dim_passengers;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dds.dim_passengers пуста. Реализуйте загрузку: sql/dds/dim_passengers_load.sql';
END IF;
SELECT COUNT(*) - COUNT(DISTINCT passenger_id) INTO v_dup FROM dds.dim_passengers;
IF v_dup > 0 THEN
RAISE EXCEPTION E'FAILED: dds.dim_passengers содержит % дублей по passenger_id.\n'
'Подсказка: INSERT без проверки EXISTS создаёт дубли при повторном запуске.\n'
'Используйте INSERT ... WHERE NOT EXISTS или ON CONFLICT DO UPDATE.', v_dup;
END IF;
SELECT COUNT(*) INTO v_missing
FROM (SELECT DISTINCT passenger_id FROM ods.tickets) AS s
WHERE NOT EXISTS (
SELECT 1 FROM dds.dim_passengers AS d WHERE d.passenger_id = s.passenger_id
);
IF v_missing > 0 THEN
RAISE EXCEPTION 'FAILED: % пассажиров из ods.tickets отсутствуют в dds.dim_passengers.', v_missing;
END IF;
RAISE NOTICE 'PASSED: dds.dim_passengers содержит % строк, нет дублей, покрывает все BK из ODS', v_count;
END $$;
+22
View File
@@ -0,0 +1,22 @@
-- Проверка: нет дублей по passenger_id в dds.dim_passengers.
--
-- Отдельный таск для точной диагностики — студент сразу видит причину проблемы.
-- Типичная ошибка: INSERT без проверки EXISTS при SCD1-загрузке.
DO $$
DECLARE
v_dup BIGINT;
BEGIN
SELECT COUNT(*) INTO v_dup
FROM (
SELECT passenger_id FROM dds.dim_passengers
GROUP BY passenger_id HAVING COUNT(*) > 1
) AS d;
IF v_dup > 0 THEN
RAISE EXCEPTION E'FAILED: dds.dim_passengers содержит % дублирующихся passenger_id.\n'
'Подсказка: при SCD1 нужно INSERT ... WHERE NOT EXISTS или ON CONFLICT DO NOTHING/UPDATE.\n'
'Если таск check_dim_passengers_exists тоже упал — начните с него.', v_dup;
END IF;
RAISE NOTICE 'PASSED: Нет дублей по passenger_id в dds.dim_passengers';
END $$;
+48
View File
@@ -0,0 +1,48 @@
-- Проверка dds.dim_routes:
-- 1. Таблица не пуста
-- 2. Покрытие ODS: у каждого route_no из ods.routes есть открытая текущая версия (valid_to IS NULL)
-- 3. SCD2-инвариант: не более одной текущей версии на route_bk (valid_to IS NULL)
--
-- Почему покрытие проверяем через valid_to IS NULL, а не просто EXISTS:
-- закрытая версия (valid_to IS NOT NULL) без открытой означает «маршрут есть в ODS,
-- но в DDS только архив» — баг вида "старую версию закрыли, новую не вставили".
-- Такой маршрут теряется в point-in-time JOIN'ах и витринах.
DO $$
DECLARE
v_count BIGINT;
v_missing BIGINT;
v_multi_current BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dds.dim_routes;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dds.dim_routes пуста. Реализуйте загрузку: sql/dds/dim_routes_load.sql';
END IF;
-- Покрытие ODS: у каждого маршрута должна быть открытая (текущая) версия
SELECT COUNT(*) INTO v_missing
FROM (SELECT DISTINCT route_no FROM ods.routes) AS s
WHERE NOT EXISTS (
SELECT 1 FROM dds.dim_routes AS d
WHERE d.route_bk = s.route_no AND d.valid_to IS NULL
);
IF v_missing > 0 THEN
RAISE EXCEPTION E'FAILED: % маршрутов из ods.routes не имеют текущей версии в dds.dim_routes (valid_to IS NULL).\n'
'Подсказка: проверьте, что после UPDATE (закрытие старой версии) выполняется INSERT новой.', v_missing;
END IF;
-- SCD2-инвариант: не более одной текущей версии на route_bk
SELECT COUNT(*) INTO v_multi_current
FROM (
SELECT route_bk FROM dds.dim_routes
WHERE valid_to IS NULL
GROUP BY route_bk HAVING COUNT(*) > 1
) AS d;
IF v_multi_current > 0 THEN
RAISE EXCEPTION E'FAILED: % маршрутов имеют более одной текущей версии (valid_to IS NULL).\n'
'Подсказка: при INSERT новой версии проверяйте NOT EXISTS ... AND hashdiff = ...\n'
'чтобы не создавать дубликат, если hashdiff не изменился.', v_multi_current;
END IF;
RAISE NOTICE 'PASSED: dds.dim_routes содержит % строк, покрывает ODS, по одной текущей версии на маршрут', v_count;
END $$;
+28
View File
@@ -0,0 +1,28 @@
-- Проверка: нет «дыр» в SCD2-интервалах dim_routes.
--
-- Для каждого route_bk с несколькими версиями проверяем, что
-- valid_to предыдущей версии = valid_from следующей.
--
-- Важно: если маршрут «исчез» из ODS, его текущая версия закрывается
-- (valid_to = CURRENT_DATE), но новая не вставляется — это корректно.
-- Проверяем «дыру» только если следующая версия существует.
DO $$
DECLARE
v_gaps BIGINT;
BEGIN
SELECT COUNT(*) INTO v_gaps
FROM (
SELECT route_bk, valid_to,
LEAD(valid_from) OVER (PARTITION BY route_bk ORDER BY valid_from) AS next_valid_from
FROM dds.dim_routes
) AS t
WHERE valid_to IS NOT NULL
AND next_valid_from IS NOT NULL -- следующая версия существует (не «исчезнувший» маршрут)
AND valid_to <> next_valid_from;
IF v_gaps > 0 THEN
RAISE EXCEPTION 'FAILED: В dds.dim_routes найдено % «дыр» между версиями SCD2. valid_to старой версии должен совпадать с valid_from новой.', v_gaps;
END IF;
RAISE NOTICE 'PASSED: Нет «дыр» в SCD2-версиях dim_routes';
END $$;
+12
View File
@@ -0,0 +1,12 @@
-- Бэкап текущего состояния перед активным тестом SCD2.
-- Используем обычные таблицы (не TEMP) — между тасками Airflow
-- TEMP-таблицы не сохраняются (каждый таск = отдельная транзакция).
DROP TABLE IF EXISTS _validate_bk_ods_routes;
CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes;
DROP TABLE IF EXISTS _validate_bk_dim_routes;
CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes;
-- Cleanup служебной таблицы от предыдущего запуска (на случай если restore не доехал)
DROP TABLE IF EXISTS _validate_scd2_target;
+95
View File
@@ -0,0 +1,95 @@
-- Проверяем, что SCD2-логика студента сработала корректно после мутации.
-- Читаем route_no тестового маршрута из служебной таблицы _validate_scd2_target
-- (создана на шаге mutate), а не угадываем по побочным эффектам.
DO $$
DECLARE
v_route TEXT;
v_version_count BIGINT;
v_closed_count BIGINT;
v_open_count BIGINT;
v_old_hash TEXT;
v_new_hash TEXT;
v_gap_count BIGINT;
BEGIN
-- Читаем тестовый маршрут из служебной таблицы
SELECT route_no INTO v_route FROM _validate_scd2_target LIMIT 1;
IF v_route IS NULL THEN
RAISE EXCEPTION 'FAILED: служебная таблица _validate_scd2_target пуста. Шаг mutate не выполнился?';
END IF;
-- Если в dim_routes нет маршрута — load не запустился или упал
IF NOT EXISTS (SELECT 1 FROM dds.dim_routes WHERE route_bk = v_route) THEN
RAISE EXCEPTION E'FAILED: dds.dim_routes не содержит маршрут % после запуска load.\n'
'Проверьте sql/dds/dim_routes_load.sql.', v_route;
END IF;
-- Проверка a: Ровно 2 версии тестового маршрута (было 1, стало 2 после мутации)
SELECT COUNT(*) INTO v_version_count
FROM dds.dim_routes WHERE route_bk = v_route;
IF v_version_count < 2 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — найдена % версия (ожидается 2: старая закрытая + новая открытая).\n'
'SCD2 должен был создать новую версию после изменения departure_time.', v_route, v_version_count;
END IF;
IF v_version_count > 2 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — найдено % версий (ожидается 2).\n'
'Возможно, load создаёт лишние дубликаты. Проверьте условие NOT EXISTS при INSERT.', v_route, v_version_count;
END IF;
-- Проверка b: Старая версия закрыта (valid_to IS NOT NULL)
SELECT COUNT(*) INTO v_closed_count
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL;
IF v_closed_count = 0 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — SCD2 не закрыл старую версию (valid_to IS NULL у всех версий).\n'
'Подсказка: hashdiff изменился (departure_time сдвинут на 1 час),\n'
'но ваш load-скрипт не обнаружил это изменение.\n'
'Проверьте:\n'
' 1. Формулу hashdiff — включает ли она departure_time?\n'
' 2. Логику сравнения hashdiff (UPDATE ... SET valid_to = CURRENT_DATE WHERE hashdiff <> новый_hashdiff)', v_route;
END IF;
-- Проверка c: Новая версия открыта (valid_to IS NULL)
SELECT COUNT(*) INTO v_open_count
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
IF v_open_count <> 1 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — ожидается ровно 1 открытая версия (valid_to IS NULL), найдено %.\n'
'Подсказка: SCD2 должен вставить новую строку с valid_to = NULL.', v_route, v_open_count;
END IF;
-- Проверка d: hashdiff старой ≠ hashdiff новой (мутация действительно отразилась в хеше)
SELECT hashdiff INTO v_old_hash
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL
ORDER BY valid_from DESC LIMIT 1;
SELECT hashdiff INTO v_new_hash
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
IF v_old_hash = v_new_hash THEN
RAISE EXCEPTION E'FAILED: Маршрут % — hashdiff старой и новой версий совпадают.\n'
'Мутация сдвинула departure_time на 1 час, но hashdiff не изменился.\n'
'Проверьте, что departure_time входит в формулу hashdiff.', v_route;
END IF;
-- Проверка e: Нет «дыры» между valid_to старой и valid_from новой
SELECT COUNT(*) INTO v_gap_count
FROM dds.dim_routes AS old_v
JOIN dds.dim_routes AS new_v
ON old_v.route_bk = new_v.route_bk
WHERE old_v.route_bk = v_route
AND old_v.valid_to IS NOT NULL
AND new_v.valid_to IS NULL
AND old_v.valid_to <> new_v.valid_from;
IF v_gap_count > 0 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — «дыра» между версиями:\n'
'valid_to старой ≠ valid_from новой.\n'
'Подсказка: полуоткрытый интервал [valid_from, valid_to).\n'
'valid_from новой версии должен = valid_to старой (обычно CURRENT_DATE).', v_route;
END IF;
RAISE NOTICE 'PASSED: SCD2 корректен для маршрута %. 2 версии, hashdiff различаются, «дыр» нет.', v_route;
END $$;
+35
View File
@@ -0,0 +1,35 @@
-- Мутация: сдвигаем departure_time у одного маршрута на 1 час.
-- Это должно изменить hashdiff → SCD2 должен закрыть старую версию и создать новую.
--
-- Сохраняем route_no тестового маршрута в служебную таблицу _validate_scd2_target,
-- чтобы check-скрипт точно знал, какой маршрут проверять (а не угадывал по побочным эффектам).
DO $$
DECLARE
v_route TEXT;
v_old_time TIME;
BEGIN
-- Берём первый маршрут, у которого departure_time заполнен
SELECT route_no, departure_time
INTO v_route, v_old_time
FROM ods.routes
WHERE departure_time IS NOT NULL
ORDER BY route_no
LIMIT 1;
IF v_route IS NULL THEN
RAISE EXCEPTION 'FAILED: ods.routes пуста или нет маршрутов с departure_time. Загрузите STG→ODS перед проверкой.';
END IF;
-- Запоминаем тестовый маршрут в служебную таблицу
DROP TABLE IF EXISTS _validate_scd2_target;
CREATE TABLE _validate_scd2_target AS
SELECT v_route AS route_no;
-- Сдвигаем время на 1 час у всех записей этого маршрута
UPDATE ods.routes
SET departure_time = departure_time + INTERVAL '1 hour'
WHERE route_no = v_route;
RAISE NOTICE 'SCD2 TEST: маршрут % — departure_time сдвинут с % на %',
v_route, v_old_time, v_old_time + INTERVAL '1 hour';
END $$;
+42
View File
@@ -0,0 +1,42 @@
-- Откат данных после активного теста SCD2.
-- Выполняется ВСЕГДА (trigger_rule="all_done"), даже если check упал.
--
-- Безопасность: если backup-шаг не создал таблицы (сбой на backup),
-- откат НЕ трогает live-данные — просто чистит служебные таблицы.
-- Это гарантирует, что restore никогда не сломает ods.routes / dds.dim_routes.
--
-- Паттерн: setup → act → assert → teardown (стандарт интеграционных тестов).
DO $$
DECLARE
v_has_ods_backup BOOLEAN;
v_has_dim_backup BOOLEAN;
BEGIN
-- Проверяем существование backup-таблиц через to_regclass
-- (ищет по search_path — совпадает с тем, как CREATE TABLE их создал)
v_has_ods_backup := to_regclass('_validate_bk_ods_routes') IS NOT NULL;
v_has_dim_backup := to_regclass('_validate_bk_dim_routes') IS NOT NULL;
-- Восстанавливаем ods.routes только если бэкап существует
IF v_has_ods_backup THEN
TRUNCATE ods.routes;
INSERT INTO ods.routes SELECT * FROM _validate_bk_ods_routes;
RAISE NOTICE 'RESTORE: ods.routes восстановлена из бэкапа';
ELSE
RAISE NOTICE 'RESTORE: бэкап ods.routes не найден — пропускаем (backup-шаг не завершился?)';
END IF;
-- Восстанавливаем dds.dim_routes только если бэкап существует
IF v_has_dim_backup THEN
TRUNCATE dds.dim_routes;
INSERT INTO dds.dim_routes SELECT * FROM _validate_bk_dim_routes;
RAISE NOTICE 'RESTORE: dds.dim_routes восстановлена из бэкапа';
ELSE
RAISE NOTICE 'RESTORE: бэкап dds.dim_routes не найден — пропускаем';
END IF;
END $$;
-- Cleanup служебных таблиц (безусловно, IF EXISTS)
DROP TABLE IF EXISTS _validate_bk_ods_routes;
DROP TABLE IF EXISTS _validate_bk_dim_routes;
DROP TABLE IF EXISTS _validate_scd2_target;
+23
View File
@@ -0,0 +1,23 @@
-- Проверка dm.monthly_overview:
-- 1. Таблица не пуста
-- 2. Нет NULL в ключевых полях (year_actual, month_actual, airplane_sk)
DO $$
DECLARE
v_count BIGINT;
v_null_pk BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dm.monthly_overview;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dm.monthly_overview пуста. Реализуйте загрузку: sql/dm/monthly_overview_load.sql';
END IF;
SELECT COUNT(*) INTO v_null_pk
FROM dm.monthly_overview
WHERE year_actual IS NULL OR month_actual IS NULL OR airplane_sk IS NULL;
IF v_null_pk > 0 THEN
RAISE EXCEPTION 'FAILED: dm.monthly_overview содержит % строк с NULL в ключе (year_actual, month_actual, airplane_sk).', v_null_pk;
END IF;
RAISE NOTICE 'PASSED: dm.monthly_overview содержит % строк', v_count;
END $$;
+56
View File
@@ -0,0 +1,56 @@
-- Проверка: ODS airplanes содержит все BK из STG-батча.
--
-- Логика: ODS — TRUNCATE+INSERT snapshot. STG — append-only история всех батчей.
-- Сравниваем не счётчики строк, а точное множество BK для того батча,
-- который ODS фактически загрузил (определяем по _load_id из ods.airplanes).
DO $$
DECLARE
v_batch_count BIGINT;
v_batch TEXT;
v_ods_count BIGINT;
v_missing_in_ods BIGINT;
v_extra_in_ods BIGINT;
BEGIN
-- ODS пуста?
SELECT COUNT(*) INTO v_ods_count FROM ods.airplanes;
IF v_ods_count = 0 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes пуста. Реализуйте загрузку: sql/ods/airplanes_load.sql';
END IF;
-- Инвариант: ODS после TRUNCATE+INSERT содержит ровно один _load_id
SELECT COUNT(DISTINCT _load_id) INTO v_batch_count FROM ods.airplanes;
IF v_batch_count <> 1 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % разных _load_id (ожидается 1 после TRUNCATE+INSERT). Проверьте, что load начинается с TRUNCATE.', v_batch_count;
END IF;
-- Определяем батч, из которого загружена ODS
SELECT DISTINCT _load_id INTO v_batch FROM ods.airplanes;
-- BK есть в STG-батче, но нет в ODS (потеряны при загрузке)
SELECT COUNT(*) INTO v_missing_in_ods
FROM (
SELECT DISTINCT airplane_code FROM stg.airplanes WHERE _load_id = v_batch
) AS stg_bk
WHERE NOT EXISTS (
SELECT 1 FROM ods.airplanes AS o WHERE o.airplane_code = stg_bk.airplane_code
);
IF v_missing_in_ods > 0 THEN
RAISE EXCEPTION 'FAILED: % самолётов из STG-батча (%) отсутствуют в ods.airplanes. Проверьте логику TRUNCATE+INSERT.', v_missing_in_ods, v_batch;
END IF;
-- BK есть в ODS, но нет в STG-батче (откуда взялись?)
SELECT COUNT(*) INTO v_extra_in_ods
FROM (
SELECT DISTINCT airplane_code FROM ods.airplanes
) AS ods_bk
WHERE NOT EXISTS (
SELECT 1 FROM stg.airplanes AS s WHERE s._load_id = v_batch AND s.airplane_code = ods_bk.airplane_code
);
IF v_extra_in_ods > 0 THEN
RAISE EXCEPTION 'FAILED: % самолётов в ods.airplanes отсутствуют в STG-батче (%). Возможно, TRUNCATE не выполнился перед INSERT.', v_extra_in_ods, v_batch;
END IF;
RAISE NOTICE 'PASSED: ods.airplanes содержит % самолётов, множество BK = STG-батч %', v_ods_count, v_batch;
END $$;
+55
View File
@@ -0,0 +1,55 @@
-- Проверка ODS-инвариантов:
-- 1. Нет дублей по BK в ods.airplanes и ods.seats.
-- 2. Обе таблицы загружены из одного согласованного батча (_load_id совпадают).
--
-- Почему важна согласованность батча:
-- ods.airplanes и ods.seats — части одного snapshot'а (TRUNCATE+INSERT из одного STG-батча).
-- Если они собраны из разных батчей (например, airplanes перезагрузили, а seats — нет),
-- JOIN между ними даст неконсистентный срез и невалидный total_seats в dim_airplanes.
DO $$
DECLARE
v_dup_airplanes BIGINT;
v_dup_seats BIGINT;
v_load_id_airplanes TEXT;
v_load_id_seats TEXT;
BEGIN
-- airplanes: BK = airplane_code
SELECT COUNT(*) INTO v_dup_airplanes
FROM (
SELECT airplane_code FROM ods.airplanes
GROUP BY airplane_code HAVING COUNT(*) > 1
) AS d;
IF v_dup_airplanes > 0 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % дублирующихся airplane_code. Проверьте, что load начинается с TRUNCATE.', v_dup_airplanes;
END IF;
-- seats: BK = (airplane_code, seat_no)
SELECT COUNT(*) INTO v_dup_seats
FROM (
SELECT airplane_code, seat_no FROM ods.seats
GROUP BY airplane_code, seat_no HAVING COUNT(*) > 1
) AS d;
IF v_dup_seats > 0 THEN
RAISE EXCEPTION 'FAILED: ods.seats содержит % дублирующихся (airplane_code, seat_no). Проверьте, что load начинается с TRUNCATE.', v_dup_seats;
END IF;
-- Согласованность батча: обе таблицы должны быть загружены из одного _load_id
SELECT DISTINCT _load_id INTO v_load_id_airplanes FROM ods.airplanes;
SELECT DISTINCT _load_id INTO v_load_id_seats FROM ods.seats;
IF v_load_id_airplanes IS NOT NULL
AND v_load_id_seats IS NOT NULL
AND v_load_id_airplanes <> v_load_id_seats
THEN
RAISE EXCEPTION E'FAILED: ods.airplanes и ods.seats загружены из разных батчей.\n'
' airplanes._load_id = %\n'
' seats._load_id = %\n'
'ODS — единый snapshot: обе таблицы должны содержать один и тот же _load_id.\n'
'Запустите оба load-скрипта (airplanes_load.sql и seats_load.sql) в одном прогоне.',
v_load_id_airplanes, v_load_id_seats;
END IF;
RAISE NOTICE 'PASSED: Нет дублей по BK в ods.airplanes и ods.seats; батч согласован (%)', v_load_id_airplanes;
END $$;
+26
View File
@@ -0,0 +1,26 @@
-- Проверка: PK-поля не содержат NULL в ods.airplanes и ods.seats.
--
-- NULL в PK ломает JOIN'ы и агрегаты в DDS/DM — такие строки «теряются» тихо.
DO $$
DECLARE
v_null_airplanes BIGINT;
v_null_seats BIGINT;
BEGIN
-- airplanes: PK = airplane_code
SELECT COUNT(*) INTO v_null_airplanes
FROM ods.airplanes WHERE airplane_code IS NULL;
IF v_null_airplanes > 0 THEN
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % строк с NULL в airplane_code.', v_null_airplanes;
END IF;
-- seats: PK = (airplane_code, seat_no)
SELECT COUNT(*) INTO v_null_seats
FROM ods.seats WHERE airplane_code IS NULL OR seat_no IS NULL;
IF v_null_seats > 0 THEN
RAISE EXCEPTION 'FAILED: ods.seats содержит % строк с NULL в PK (airplane_code, seat_no).', v_null_seats;
END IF;
RAISE NOTICE 'PASSED: PK не содержат NULL в ods.airplanes и ods.seats';
END $$;
+55
View File
@@ -0,0 +1,55 @@
-- Проверка: ODS seats содержит все BK из STG-батча.
--
-- Аналог ods_airplanes_rowcount.sql, но для seats с составным BK (airplane_code, seat_no).
DO $$
DECLARE
v_batch_count BIGINT;
v_batch TEXT;
v_ods_count BIGINT;
v_missing_in_ods BIGINT;
v_extra_in_ods BIGINT;
BEGIN
-- ODS пуста?
SELECT COUNT(*) INTO v_ods_count FROM ods.seats;
IF v_ods_count = 0 THEN
RAISE EXCEPTION 'FAILED: ods.seats пуста. Реализуйте загрузку: sql/ods/seats_load.sql';
END IF;
-- Инвариант: ODS после TRUNCATE+INSERT содержит ровно один _load_id
SELECT COUNT(DISTINCT _load_id) INTO v_batch_count FROM ods.seats;
IF v_batch_count <> 1 THEN
RAISE EXCEPTION 'FAILED: ods.seats содержит % разных _load_id (ожидается 1 после TRUNCATE+INSERT). Проверьте, что load начинается с TRUNCATE.', v_batch_count;
END IF;
SELECT DISTINCT _load_id INTO v_batch FROM ods.seats;
-- BK есть в STG-батче, но нет в ODS (потеряны при загрузке)
SELECT COUNT(*) INTO v_missing_in_ods
FROM (
SELECT DISTINCT airplane_code, seat_no FROM stg.seats WHERE _load_id = v_batch
) AS stg_bk
WHERE NOT EXISTS (
SELECT 1 FROM ods.seats AS o
WHERE o.airplane_code = stg_bk.airplane_code AND o.seat_no = stg_bk.seat_no
);
IF v_missing_in_ods > 0 THEN
RAISE EXCEPTION 'FAILED: % мест из STG-батча (%) отсутствуют в ods.seats. Проверьте логику TRUNCATE+INSERT.', v_missing_in_ods, v_batch;
END IF;
-- BK есть в ODS, но нет в STG-батче
SELECT COUNT(*) INTO v_extra_in_ods
FROM (
SELECT DISTINCT airplane_code, seat_no FROM ods.seats
) AS ods_bk
WHERE NOT EXISTS (
SELECT 1 FROM stg.seats AS s
WHERE s._load_id = v_batch AND s.airplane_code = ods_bk.airplane_code AND s.seat_no = ods_bk.seat_no
);
IF v_extra_in_ods > 0 THEN
RAISE EXCEPTION 'FAILED: % мест в ods.seats отсутствуют в STG-батче (%). Возможно, TRUNCATE не выполнился перед INSERT.', v_extra_in_ods, v_batch;
END IF;
RAISE NOTICE 'PASSED: ods.seats содержит % мест, множество BK = STG-батч %', v_ods_count, v_batch;
END $$;
+23
View File
@@ -0,0 +1,23 @@
-- Проверка dm.passenger_loyalty:
-- 1. Таблица не пуста
-- 2. Нет NULL в ключевом поле (passenger_sk)
DO $$
DECLARE
v_count BIGINT;
v_null_pk BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dm.passenger_loyalty;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dm.passenger_loyalty пуста. Реализуйте загрузку: sql/dm/passenger_loyalty_load.sql';
END IF;
SELECT COUNT(*) INTO v_null_pk
FROM dm.passenger_loyalty
WHERE passenger_sk IS NULL;
IF v_null_pk > 0 THEN
RAISE EXCEPTION 'FAILED: dm.passenger_loyalty содержит % строк с NULL в ключе (passenger_sk).', v_null_pk;
END IF;
RAISE NOTICE 'PASSED: dm.passenger_loyalty содержит % строк', v_count;
END $$;
+23
View File
@@ -0,0 +1,23 @@
-- Проверка dm.route_performance:
-- 1. Таблица не пуста
-- 2. Нет NULL в ключевом поле (route_bk)
DO $$
DECLARE
v_count BIGINT;
v_null_pk BIGINT;
BEGIN
SELECT COUNT(*) INTO v_count FROM dm.route_performance;
IF v_count = 0 THEN
RAISE EXCEPTION 'FAILED: dm.route_performance пуста. Реализуйте загрузку: sql/dm/route_performance_load.sql';
END IF;
SELECT COUNT(*) INTO v_null_pk
FROM dm.route_performance
WHERE route_bk IS NULL;
IF v_null_pk > 0 THEN
RAISE EXCEPTION 'FAILED: dm.route_performance содержит % строк с NULL в ключе (route_bk).', v_null_pk;
END IF;
RAISE NOTICE 'PASSED: dm.route_performance содержит % строк', v_count;
END $$;
+60
View File
@@ -369,6 +369,66 @@ def test_bookings_dm_ddl_dag_structure():
) )
class TestBookingsValidate:
"""Smoke-тесты DAG bookings_validate."""
def test_dag_loads(self):
dag = _load_dag("airflow.dags.bookings_validate")
assert dag is not None
def test_expected_tasks(self):
dag = _load_dag("airflow.dags.bookings_validate")
expected = {
# ODS
"validate_ods.check_ods_airplanes_rowcount",
"validate_ods.check_ods_seats_rowcount",
"validate_ods.check_ods_no_dup_bk",
"validate_ods.check_ods_no_null_pks",
# DDS
"validate_dds.check_dim_airplanes_exists",
"validate_dds.check_dim_passengers_exists",
"validate_dds.check_dim_passengers_no_dup_bk",
"validate_dds.check_dim_routes_exists",
"validate_dds.scd2_backup",
"validate_dds.scd2_mutate",
"validate_dds.scd2_run_student_load",
"validate_dds.scd2_check",
"validate_dds.scd2_restore",
"validate_dds.check_dim_routes_no_gaps",
# DM
"validate_dm.check_airport_traffic_exists",
"validate_dm.check_route_performance_exists",
"validate_dm.check_monthly_overview_exists",
"validate_dm.check_passenger_loyalty_exists",
}
assert expected.issubset(dag.task_dict.keys())
def test_scd2_chain(self):
"""SCD2 цепочка backup → mutate → load → check → restore."""
dag = _load_dag("airflow.dags.bookings_validate")
_assert_direct_edge(dag, "validate_dds.scd2_backup", "validate_dds.scd2_mutate")
_assert_direct_edge(
dag, "validate_dds.scd2_mutate", "validate_dds.scd2_run_student_load"
)
_assert_direct_edge(
dag, "validate_dds.scd2_run_student_load", "validate_dds.scd2_check"
)
_assert_direct_edge(dag, "validate_dds.scd2_check", "validate_dds.scd2_restore")
def test_no_gaps_after_restore(self):
"""no_gaps должен выполняться после restore (на чистых данных)."""
dag = _load_dag("airflow.dags.bookings_validate")
_assert_direct_edge(
dag, "validate_dds.scd2_restore", "validate_dds.check_dim_routes_no_gaps"
)
def test_restore_trigger_rule(self):
"""restore должен выполняться всегда (all_done), даже если check упал."""
dag = _load_dag("airflow.dags.bookings_validate")
restore_task = dag.task_dict["validate_dds.scd2_restore"]
assert restore_task.trigger_rule == "all_done"
def test_bookings_to_gp_dm_dag_structure(): def test_bookings_to_gp_dm_dag_structure():
"""Проверка структуры DAG bookings_to_gp_dm.""" """Проверка структуры DAG bookings_to_gp_dm."""
dag = _load_dag("airflow.dags.bookings_to_gp_dm") dag = _load_dag("airflow.dags.bookings_to_gp_dm")