Чистовик статьи + материалы

This commit is contained in:
2025-11-07 11:05:44 +03:00
parent fa42a4508f
commit dfc5748358
15 changed files with 1912 additions and 1 deletions
+125
View File
@@ -0,0 +1,125 @@
-- ===============================================
-- DDL-скрипт: определение структуры хранилища
-- Запускается ОДИН РАЗ при инициализации БД
-- или при изменении схемы (миграции)
-- ===============================================
-- 1. Схемы
DROP SCHEMA IF EXISTS stg CASCADE;
DROP SCHEMA IF EXISTS ods CASCADE;
DROP SCHEMA IF EXISTS dds CASCADE;
CREATE SCHEMA stg;
CREATE SCHEMA ods;
CREATE SCHEMA dds;
-- 2. STG: сырые данные (как пришли)
CREATE TABLE stg.customers_raw (
customer_id TEXT,
email TEXT,
phone TEXT,
city TEXT,
_load_ts TIMESTAMP DEFAULT NOW()
);
CREATE TABLE stg.orders_raw (
order_id TEXT,
order_date TEXT,
customer_id TEXT
);
CREATE TABLE stg.order_items_raw (
order_item_id TEXT,
order_id TEXT,
product_id TEXT,
qty TEXT,
price_at_sale TEXT
);
CREATE TABLE stg.products_raw (
product_id TEXT,
name TEXT
);
-- 3. ODS: очищенные данные
CREATE TABLE ods.customers (
customer_id INT,
email VARCHAR(100),
phone VARCHAR(20),
city VARCHAR(50)
);
CREATE TABLE ods.orders (
order_id INT,
order_date DATE,
customer_id INT
);
CREATE TABLE ods.order_items (
order_item_id INT,
order_id INT,
product_id INT,
qty INT,
price_at_sale NUMERIC(10,2)
);
CREATE TABLE ods.products (
product_id INT,
name VARCHAR(100)
);
-- Первичные ключи в ODS (для ускорения и валидации)
ALTER TABLE ods.customers ADD PRIMARY KEY (customer_id);
ALTER TABLE ods.orders ADD PRIMARY KEY (order_id);
ALTER TABLE ods.order_items ADD PRIMARY KEY (order_item_id);
ALTER TABLE ods.products ADD PRIMARY KEY (product_id);
-- 4. DDS: интегрированная модель
-- dim_date: справочник дат (без первичного ключа — генерируется)
CREATE TABLE dds.dim_date (
date_key INT PRIMARY KEY,
date_actual DATE NOT NULL,
year SMALLINT,
quarter SMALLINT,
month SMALLINT,
day SMALLINT,
weekday_name VARCHAR(10),
weekday_num SMALLINT,
is_first_week BOOLEAN
);
-- dim_product: измерение "Товар"
CREATE TABLE dds.dim_product (
product_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_bk INT NOT NULL,
product_name VARCHAR(100) NOT NULL
);
-- dim_customer: измерение "Клиент" с SCD Type 2
CREATE TABLE dds.dim_customer (
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_bk INT NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
city VARCHAR(50),
valid_from DATE NOT NULL,
valid_to DATE NOT NULL DEFAULT '9999-12-31',
is_current BOOLEAN NOT NULL DEFAULT TRUE
);
-- fact_sales: факт "Продажи"
CREATE TABLE dds.fact_sales (
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_sk BIGINT NOT NULL,
product_sk BIGINT NOT NULL,
date_key INT NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
amount NUMERIC(18,2) NOT NULL CHECK (amount >= 0)
);
-- Внешние ключи (опционально — в продакшене часто отключают ради скорости)
ALTER TABLE dds.fact_sales
ADD CONSTRAINT fk_fact_customer FOREIGN KEY (customer_sk) REFERENCES dds.dim_customer(customer_sk),
ADD CONSTRAINT fk_fact_product FOREIGN KEY (product_sk) REFERENCES dds.dim_product(product_sk),
ADD CONSTRAINT fk_fact_date FOREIGN KEY (date_key) REFERENCES dds.dim_date(date_key);
+182
View File
@@ -0,0 +1,182 @@
-- ===============================================
-- DML-скрипт: загрузка и трансформация данных
-- Запускается ПОВТОРНО при каждой загрузке (идемпотентно!)
-- ===============================================
-- 1. STG: имитация загрузки из источников (в реальности — COPY или INSERT из Kafka/NiFi)
-- ⚠️ В продакшене STG часто очищается перед загрузкой (TRUNCATE), либо используется партицирование по дате
DELETE FROM stg.customers_raw;
DELETE FROM stg.orders_raw;
DELETE FROM stg.order_items_raw;
DELETE FROM stg.products_raw;
INSERT INTO stg.customers_raw (customer_id, email, phone, city) VALUES
('101', 'a@ex.com', '700', 'Москва'),
('101', 'b@ex.com', '700', 'Москва'),
('102', 'c@ex.com', '701', 'СПб');
INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES
('5001', '2024-01-10', '101'),
('5002', '2024-02-05', '102');
INSERT INTO stg.order_items_raw (order_item_id, order_id, product_id, qty, price_at_sale) VALUES
('1', '5001', '9001', '2', '100.00'),
('2', '5001', '9002', '1', '50.00'),
('3', '5002', '9001', '1', '100.00');
INSERT INTO stg.products_raw (product_id, name) VALUES
('9001', 'Phone'),
('9002', 'Case');
-- 2. ODS: очистка и типизация
-- ⚠️ В продакшене используем UPSERT или incremental load, не TRUNCATE+INSERT
TRUNCATE ods.customers, ods.orders, ods.order_items, ods.products;
INSERT INTO ods.customers (customer_id, email, phone, city)
SELECT
customer_id::INT,
NULLIF(TRIM(email), ''),
NULLIF(TRIM(phone), ''),
NULLIF(TRIM(city), '')
FROM stg.customers_raw
WHERE customer_id ~ '^\d+$';
INSERT INTO ods.orders (order_id, order_date, customer_id)
SELECT
order_id::INT,
TO_DATE(order_date, 'YYYY-MM-DD'),
customer_id::INT
FROM stg.orders_raw
WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$';
INSERT INTO ods.order_items (order_item_id, order_id, product_id, qty, price_at_sale)
SELECT
order_item_id::INT,
order_id::INT,
product_id::INT,
NULLIF(qty, '')::INT,
NULLIF(price_at_sale, '')::NUMERIC(10,2)
FROM stg.order_items_raw
WHERE qty ~ '^\d+$' AND price_at_sale ~ '^\d+(\.\d+)?$';
INSERT INTO ods.products (product_id, name)
SELECT
product_id::INT,
TRIM(name)
FROM stg.products_raw
WHERE product_id ~ '^\d+$';
-- 3. DDS: dim_date — генерация календаря (идемпотентно: можно пересоздавать)
-- В реальности — делается ОДИН РАЗ, либо дополняется по мере необходимости
DELETE FROM dds.dim_date;
WITH RECURSIVE dates AS (
SELECT DATE '2023-01-01' AS d
UNION ALL
SELECT d + INTERVAL '1 day'
FROM dates
WHERE d + INTERVAL '1 day' <= DATE '2027-12-31'
)
INSERT INTO dds.dim_date (
date_key, date_actual, year, quarter, month, day,
weekday_name, weekday_num, is_first_week
)
SELECT
CAST(TO_CHAR(d, 'YYYYMMDD') AS INT),
d,
EXTRACT(YEAR FROM d)::SMALLINT,
EXTRACT(QUARTER FROM d)::SMALLINT,
EXTRACT(MONTH FROM d)::SMALLINT,
EXTRACT(DAY FROM d)::SMALLINT,
TO_CHAR(d, 'Day'),
EXTRACT(DOW FROM d)::SMALLINT,
d BETWEEN DATE_TRUNC('month', d)
AND DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day'
AND EXTRACT(DAY FROM d) <= 7
FROM dates;
-- 4. DDS: dim_product — полная перезагрузка (если товары редко меняются)
-- В реальности — инкрементальная загрузка по BK
DELETE FROM dds.dim_product;
INSERT INTO dds.dim_product (product_bk, product_name)
SELECT product_id, name
FROM ods.products;
-- 5. DDS: dim_customer — SCD Type 2 (упрощённая версия для обучения)
-- В продакшене — используем алгоритм «детектирования изменений + UPSERT»
-- Здесь: перестраиваем всю историю на основе STG (для детерминированности)
DELETE FROM dds.dim_customer;
WITH ranked AS (
SELECT
customer_id::INT AS customer_bk,
email,
phone,
city,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY _load_ts, email
) AS rn
FROM stg.customers_raw
WHERE customer_id ~ '^\d+$'
),
changes AS (
SELECT
customer_bk,
email,
phone,
city,
-- Имитируем хронологию: +1 день на каждое изменение
'2023-01-01'::DATE + (rn - 1) * INTERVAL '1 day' AS eff_from
FROM ranked
),
final AS (
SELECT
customer_bk,
email,
phone,
city,
eff_from::DATE AS valid_from,
COALESCE(
LEAD(eff_from) OVER (PARTITION BY customer_bk ORDER BY eff_from) - INTERVAL '1 day',
'9999-12-31'::DATE
) AS valid_to,
CASE WHEN LEAD(eff_from) OVER (PARTITION BY customer_b_k ORDER BY eff_from) IS NULL
THEN TRUE ELSE FALSE END AS is_current
FROM changes
)
INSERT INTO dds.dim_customer (customer_bk, email, phone, city, valid_from, valid_to, is_current)
SELECT customer_bk, email, phone, city, valid_from, valid_to, is_current
FROM final;
-- 6. DDS: fact_sales — загрузка фактов с учётом SCD
-- В продакшене — фильтруем по диапазону дат (инкрементально)
DELETE FROM dds.fact_sales;
INSERT INTO dds.fact_sales (customer_sk, product_sk, date_key, quantity, amount)
SELECT
dc.customer_sk,
dp.product_sk,
CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT),
oi.qty,
oi.price_at_sale * oi.qty
FROM ods.orders o
JOIN ods.order_items oi ON o.order_id = oi.order_id
JOIN ods.products p ON oi.product_id = p.product_id
JOIN dds.dim_product dp ON p.product_id = dp.product_bk
JOIN dds.dim_customer dc
ON o.customer_id = dc.customer_bk
AND o.order_date BETWEEN dc.valid_from AND dc.valid_to;
-- 7. Проверка — вывод итогов (не часть ETL, но полезно для отладки)
-- В реальном пайплайне такие SELECT выносятся в отдельные скрипты или дашборды
-- SELECT 'dim_customer count = ' || COUNT(*) FROM dds.dim_customer;
-- SELECT 'fact_sales count = ' || COUNT(*) FROM dds.fact_sales;
+98
View File
@@ -0,0 +1,98 @@
-- ===============================================
-- Проверки качества данных после загрузки STG→ODS→DDS
-- Запускается после 02_dml.sql
-- ===============================================
-- 1. Проверка: dim_customer не пуста
DO $$
BEGIN
ASSERT (SELECT COUNT(*) FROM dds.dim_customer) > 0,
'ОШИБКА: таблица dds.dim_customer пуста — загрузка не прошла';
RAISE NOTICE '✅ dim_customer: НЕ ПУСТА (всего строк: %)',
(SELECT COUNT(*) FROM dds.dim_customer);
END $$;
-- 2. Проверка: fact_sales содержит все строки из order_items
DO $$
DECLARE
expected_count INT := (SELECT COUNT(*) FROM ods.order_items);
actual_count INT := (SELECT COUNT(*) FROM dds.fact_sales);
BEGIN
ASSERT actual_count = expected_count,
FORMAT('ОШИБКА: в fact_sales %s строк, а в ods.order_items — %s. Разница: %s',
actual_count, expected_count, expected_count - actual_count);
RAISE NOTICE '✅ fact_sales: количество строк совпадает с ods.order_items (%)', actual_count;
END $$;
-- 3. Проверка: у каждого факта есть валидная дата (date_key существует)
DO $$
DECLARE missing_dates INT;
BEGIN
SELECT COUNT(*) INTO missing_dates
FROM dds.fact_sales f
LEFT JOIN dds.dim_date d ON f.date_key = d.date_key
WHERE d.date_key IS NULL;
ASSERT missing_dates = 0,
FORMAT('ОШИБКА: %s фактов ссылаются на несуществующие даты (неверный date_key)', missing_dates);
RAISE NOTICE '✅ Все факты имеют валидные date_key';
END $$;
-- 4. Проверка SCD Type 2: у клиента 101 должно быть ≥2 версий (из-за смены email)
DO $$
DECLARE version_count INT;
BEGIN
SELECT COUNT(*) INTO version_count
FROM dds.dim_customer
WHERE customer_bk = 101;
ASSERT version_count >= 2,
FORMAT('ОШИБКА: у клиента 101 только %s версия, ожидается ≥2 (должна быть история)', version_count);
RAISE NOTICE '✅ SCD Type 2: клиент 101 имеет %s версий — история сохранена', version_count;
END $$;
-- 5. Проверка: сумма amount = qty * price_at_sale (без округления)
DO $$
DECLARE bad_rows INT;
BEGIN
SELECT COUNT(*) INTO bad_rows
FROM (
SELECT
f.sale_id,
f.amount,
oi.qty * oi.price_at_sale AS expected_amount
FROM dds.fact_sales f
JOIN ods.orders o ON f.date_key = CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT)
JOIN ods.order_items oi ON o.order_id = oi.order_id
WHERE ROUND(f.amount, 2) <> ROUND(oi.qty * oi.price_at_sale, 2)
LIMIT 10
) mismatches;
ASSERT bad_rows = 0,
'ОШИБКА: обнаружены расхождения между amount и qty * price_at_sale';
RAISE NOTICE '✅ Все суммы рассчитаны верно (amount = qty × price_at_sale)';
END $$;
-- ===============================================
-- Финальный отчёт для аналитика
-- ===============================================
RAISE NOTICE '──────────────────────────────';
RAISE NOTICE '📊 ОТЧЁТ: Выручка по клиентам (актуальные версии)';
RAISE NOTICE '──────────────────────────────';
SELECT
dc.customer_bk AS "ID клиента",
dc.email AS "Email",
dc.city AS "Город",
SUM(f.quantity) AS "Всего товаров",
SUM(f.amount) AS "Выручка, ₽"
FROM dds.fact_sales f
JOIN dds.dim_customer dc
ON f.customer_sk = dc.customer_sk
AND dc.is_current -- только актуальная версия
GROUP BY dc.customer_bk, dc.email, dc.city
ORDER BY SUM(f.amount) DESC;
RAISE NOTICE '──────────────────────────────';
RAISE NOTICE '✅ Все проверки пройдены. DWH готов к построению витрин.';