refactor(sql): reorganize sql files into structured directory hierarchy
Move DDL files from flat ddl/ directory to sql/ddl/ with layer-based subdirectories (stg, ods, dds, dm). Move batch transformation SQL from jobs/ to sql/ layer directories. Update scripts and documentation to reflect new paths for improved organization and Airflow integration.
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
-- ============================================================================
|
||||
-- Слой ODS (Operational Data Store) — типизированные данные + дедупликация + DQ
|
||||
-- ============================================================================
|
||||
-- Назначение:
|
||||
-- - Типизация данных из STG (String → UUID, DateTime, etc.)
|
||||
-- - Дедупликация через ReplacingMergeTree (последняя версия по src_ingest_ts)
|
||||
-- - Контроль качества: массив parse_errors для "грязных" данных
|
||||
-- - Разделение: валидные строки → основная таблица, ошибки → *_errors
|
||||
--
|
||||
-- Поток данных:
|
||||
-- STG (*_raw) → MV → ODS (основная таблица + error_tables)
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- BROWSER EVENTS
|
||||
-- ============================================================================
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Основная таблица: валидные строки (event_id IS NOT NULL)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ReplacingMergeTree: при мердже оставляет строку с максимальным src_ingest_ts
|
||||
-- allow_nullable_key = 1: разрешаем NULL в ключе (ClickHouse по умолчанию запрещает)
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ods.browser_event
|
||||
(
|
||||
event_id Nullable(UUID), -- UUID события (ключ)
|
||||
event_ts Nullable(DateTime64(6)), -- Время события из JSON
|
||||
event_date Date MATERIALIZED ifNull(toDate(event_ts), toDate(src_ingest_ts)), -- Партиция
|
||||
event_type LowCardinality(Nullable(String)), -- Тип события (pageview, click и т.д.)
|
||||
click_id Nullable(UUID), -- Связь с click-контекстом
|
||||
browser_name LowCardinality(Nullable(String)), -- Chrome, Firefox и т.д.
|
||||
browser_user_agent Nullable(String), -- User-Agent строка
|
||||
browser_language LowCardinality(Nullable(String)), -- Язык браузера
|
||||
src_ingest_ts DateTime64(3), -- Время загрузки в ODS (версия для ReplacingMergeTree)
|
||||
src_raw String, -- Исходный JSON для аудита
|
||||
parse_errors Array(LowCardinality(String)) -- Ошибки парсинга (если есть)
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(src_ingest_ts) -- Движок дедупликации по версии
|
||||
PARTITION BY toYYYYMM(event_date) -- Партиции по месяцу для быстрой очистки
|
||||
ORDER BY (event_id) -- Ключ сортировки (и дедупликации)
|
||||
SETTINGS allow_nullable_key = 1; -- Разрешаем NULL в ключе (для "битых" данных)
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Таблица ошибок: строки с невалидными ключами (event_id IS NULL)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Сохраняем полную информацию для анализа проблем с данными
|
||||
-- MergeTree без Replacing: сохраняем все ошибки (не дедуплицируем)
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ods.browser_event_errors
|
||||
(
|
||||
ingest_ts DateTime64(3), -- Время вставки в ClickHouse
|
||||
kafka_topic LowCardinality(String), -- Топик Kafka (для отслеживания источника)
|
||||
kafka_partition Int32, -- Партиция Kafka
|
||||
kafka_offset Int64, -- Смещение Kafka (уникальный идентификатор сообщения)
|
||||
kafka_ts DateTime64(3), -- Время из Kafka
|
||||
raw String, -- Исходный JSON
|
||||
error_reason LowCardinality(String) -- Описание ошибки (что именно не распарсилось)
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(ingest_ts)
|
||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- MV: STG → ODS (основная таблица)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Фильтруем только валидные строки: WHERE event_id IS NOT NULL
|
||||
-- Парсим JSON, типизируем поля, собираем ошибки в массив parse_errors
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_browser_raw_to_ods
|
||||
TO ods.browser_event
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
||||
parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts,
|
||||
JSONExtractString(raw, 'event_type') AS event_type,
|
||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||
JSONExtractString(raw, 'browser_name') AS browser_name,
|
||||
JSONExtractString(raw, 'browser_user_agent') AS browser_user_agent,
|
||||
JSONExtractString(raw, 'browser_language') AS browser_language
|
||||
SELECT
|
||||
event_id,
|
||||
event_ts,
|
||||
event_type,
|
||||
click_id,
|
||||
browser_name,
|
||||
browser_user_agent,
|
||||
browser_language,
|
||||
ingest_ts AS src_ingest_ts,
|
||||
raw AS src_raw,
|
||||
-- Собираем ошибки парсинга в массив (пустые строки фильтруем)
|
||||
arrayFilter(x -> x != '', [
|
||||
if(event_id IS NULL, 'bad_event_id', ''),
|
||||
if(event_ts IS NULL, 'bad_event_timestamp', ''),
|
||||
if(click_id IS NULL, 'bad_click_id', '')
|
||||
]) AS parse_errors
|
||||
FROM stg.browser_raw
|
||||
WHERE event_id IS NOT NULL; -- Только валидные строки (NULL → в error_tables)
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- MV: STG → ODS (таблица ошибок)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Перенаправляем строки с ошибками парсинга в отдельную таблицу
|
||||
-- Это позволяет не терять данные и анализировать проблемы
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_browser_raw_to_ods_errors
|
||||
TO ods.browser_event_errors
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
||||
parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts,
|
||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||
arrayFilter(x -> x != '', [
|
||||
if(event_id IS NULL, 'bad_event_id', ''),
|
||||
if(event_ts IS NULL, 'bad_event_timestamp', ''),
|
||||
if(click_id IS NULL, 'bad_click_id', '')
|
||||
]) AS parse_errors
|
||||
SELECT
|
||||
ingest_ts,
|
||||
kafka_topic,
|
||||
kafka_partition,
|
||||
kafka_offset,
|
||||
kafka_ts,
|
||||
raw,
|
||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||
FROM stg.browser_raw
|
||||
WHERE length(parse_errors) > 0 -- Есть хотя бы одна ошибка
|
||||
AND (
|
||||
event_id IS NULL
|
||||
OR event_ts IS NULL
|
||||
OR click_id IS NULL
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- LOCATION EVENTS
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.location_event
|
||||
(
|
||||
event_id Nullable(UUID),
|
||||
page_url Nullable(String), -- Полный URL страницы
|
||||
page_url_path LowCardinality(Nullable(String)), -- Путь (/home, /product и т.д.)
|
||||
referer_url Nullable(String), -- Откуда пришёл пользователь
|
||||
referer_medium LowCardinality(Nullable(String)), -- Тип referer (internal, search и т.д.)
|
||||
utm_medium LowCardinality(Nullable(String)), -- UTM medium (cpc, organic и т.д.)
|
||||
utm_source LowCardinality(Nullable(String)), -- UTM source (google, mailchimp и т.д.)
|
||||
utm_content LowCardinality(Nullable(String)), -- UTM content (ad_1, ad_2 и т.д.)
|
||||
utm_campaign LowCardinality(Nullable(String)), -- UTM campaign (campaign_1 и т.д.)
|
||||
src_ingest_ts DateTime64(3),
|
||||
src_raw String,
|
||||
parse_errors Array(LowCardinality(String))
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||
PARTITION BY toYYYYMM(toDate(src_ingest_ts)) -- Партиция по времени загрузки (нет event_date)
|
||||
ORDER BY (event_id)
|
||||
SETTINGS allow_nullable_key = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.location_event_errors
|
||||
(
|
||||
ingest_ts DateTime64(3),
|
||||
kafka_topic LowCardinality(String),
|
||||
kafka_partition Int32,
|
||||
kafka_offset Int64,
|
||||
kafka_ts DateTime64(3),
|
||||
raw String,
|
||||
error_reason LowCardinality(String)
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(ingest_ts)
|
||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_location_raw_to_ods
|
||||
TO ods.location_event
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id
|
||||
SELECT
|
||||
event_id,
|
||||
JSONExtractString(raw, 'page_url') AS page_url,
|
||||
JSONExtractString(raw, 'page_url_path') AS page_url_path,
|
||||
JSONExtractString(raw, 'referer_url') AS referer_url,
|
||||
JSONExtractString(raw, 'referer_medium') AS referer_medium,
|
||||
JSONExtractString(raw, 'utm_medium') AS utm_medium,
|
||||
JSONExtractString(raw, 'utm_source') AS utm_source,
|
||||
JSONExtractString(raw, 'utm_content') AS utm_content,
|
||||
JSONExtractString(raw, 'utm_campaign') AS utm_campaign,
|
||||
ingest_ts AS src_ingest_ts,
|
||||
raw AS src_raw,
|
||||
arrayFilter(x -> x != '', [
|
||||
if(event_id IS NULL, 'bad_event_id', '')
|
||||
]) AS parse_errors
|
||||
FROM stg.location_raw
|
||||
WHERE event_id IS NOT NULL;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_location_raw_to_ods_errors
|
||||
TO ods.location_event_errors
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
||||
arrayFilter(x -> x != '', [
|
||||
if(event_id IS NULL, 'bad_event_id', '')
|
||||
]) AS parse_errors
|
||||
SELECT
|
||||
ingest_ts,
|
||||
kafka_topic,
|
||||
kafka_partition,
|
||||
kafka_offset,
|
||||
kafka_ts,
|
||||
raw,
|
||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||
FROM stg.location_raw
|
||||
WHERE length(parse_errors) > 0
|
||||
AND event_id IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- DEVICE EVENTS
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.device_by_click
|
||||
(
|
||||
click_id Nullable(UUID),
|
||||
os Nullable(String), -- Полное название ОС
|
||||
os_name LowCardinality(Nullable(String)), -- Короткое название (Windows, iOS и т.д.)
|
||||
os_timezone LowCardinality(Nullable(String)), -- Таймзона пользователя
|
||||
device_type LowCardinality(Nullable(String)), -- Mobile, Computer, Tablet
|
||||
device_is_mobile Nullable(UInt8), -- 1 = мобильное, 0 = десктоп
|
||||
user_custom_id Nullable(String), -- Email или username
|
||||
user_domain_id Nullable(UUID), -- UUID пользователя в системе
|
||||
src_ingest_ts DateTime64(3),
|
||||
src_raw String,
|
||||
parse_errors Array(LowCardinality(String))
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||
PARTITION BY toYYYYMM(toDate(src_ingest_ts))
|
||||
ORDER BY (click_id)
|
||||
SETTINGS allow_nullable_key = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.device_by_click_errors
|
||||
(
|
||||
ingest_ts DateTime64(3),
|
||||
kafka_topic LowCardinality(String),
|
||||
kafka_partition Int32,
|
||||
kafka_offset Int64,
|
||||
kafka_ts DateTime64(3),
|
||||
raw String,
|
||||
error_reason LowCardinality(String)
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(ingest_ts)
|
||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_device_raw_to_ods
|
||||
TO ods.device_by_click
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||
JSONExtract(raw, 'device_is_mobile', 'Nullable(UInt8)') AS device_is_mobile,
|
||||
toUUIDOrNull(JSONExtractString(raw, 'user_domain_id')) AS user_domain_id
|
||||
SELECT
|
||||
click_id,
|
||||
JSONExtractString(raw, 'os') AS os,
|
||||
JSONExtractString(raw, 'os_name') AS os_name,
|
||||
JSONExtractString(raw, 'os_timezone') AS os_timezone,
|
||||
JSONExtractString(raw, 'device_type') AS device_type,
|
||||
device_is_mobile,
|
||||
JSONExtractString(raw, 'user_custom_id') AS user_custom_id,
|
||||
user_domain_id,
|
||||
ingest_ts AS src_ingest_ts,
|
||||
raw AS src_raw,
|
||||
arrayFilter(x -> x != '', [
|
||||
if(click_id IS NULL, 'bad_click_id', ''),
|
||||
if(user_domain_id IS NULL, 'bad_user_domain_id', '')
|
||||
]) AS parse_errors
|
||||
FROM stg.device_raw
|
||||
WHERE click_id IS NOT NULL;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_device_raw_to_ods_errors
|
||||
TO ods.device_by_click_errors
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||
toUUIDOrNull(JSONExtractString(raw, 'user_domain_id')) AS user_domain_id,
|
||||
arrayFilter(x -> x != '', [
|
||||
if(click_id IS NULL, 'bad_click_id', ''),
|
||||
if(user_domain_id IS NULL, 'bad_user_domain_id', '')
|
||||
]) AS parse_errors
|
||||
SELECT
|
||||
ingest_ts,
|
||||
kafka_topic,
|
||||
kafka_partition,
|
||||
kafka_offset,
|
||||
kafka_ts,
|
||||
raw,
|
||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||
FROM stg.device_raw
|
||||
WHERE length(parse_errors) > 0
|
||||
AND (
|
||||
click_id IS NULL
|
||||
OR user_domain_id IS NULL
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- GEO EVENTS
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.geo_by_click
|
||||
(
|
||||
click_id Nullable(UUID),
|
||||
geo_latitude Nullable(Float64), -- Широта
|
||||
geo_longitude Nullable(Float64), -- Долгота
|
||||
geo_country LowCardinality(Nullable(String)), -- Код страны (RU, US и т.д.)
|
||||
geo_timezone LowCardinality(Nullable(String)), -- Таймзона
|
||||
geo_region_name Nullable(String), -- Название региона/города
|
||||
ip_address Nullable(String), -- IP адрес
|
||||
src_ingest_ts DateTime64(3),
|
||||
src_raw String,
|
||||
parse_errors Array(LowCardinality(String))
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||
PARTITION BY toYYYYMM(toDate(src_ingest_ts))
|
||||
ORDER BY (click_id)
|
||||
SETTINGS allow_nullable_key = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.geo_by_click_errors
|
||||
(
|
||||
ingest_ts DateTime64(3),
|
||||
kafka_topic LowCardinality(String),
|
||||
kafka_partition Int32,
|
||||
kafka_offset Int64,
|
||||
kafka_ts DateTime64(3),
|
||||
raw String,
|
||||
error_reason LowCardinality(String)
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(ingest_ts)
|
||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_geo_raw_to_ods
|
||||
TO ods.geo_by_click
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude,
|
||||
toFloat64OrNull(JSONExtractString(raw, 'geo_longitude')) AS geo_longitude
|
||||
SELECT
|
||||
click_id,
|
||||
geo_latitude,
|
||||
geo_longitude,
|
||||
JSONExtractString(raw, 'geo_country') AS geo_country,
|
||||
JSONExtractString(raw, 'geo_timezone') AS geo_timezone,
|
||||
JSONExtractString(raw, 'geo_region_name') AS geo_region_name,
|
||||
JSONExtractString(raw, 'ip_address') AS ip_address,
|
||||
ingest_ts AS src_ingest_ts,
|
||||
raw AS src_raw,
|
||||
arrayFilter(x -> x != '', [
|
||||
if(click_id IS NULL, 'bad_click_id', ''),
|
||||
if(geo_latitude IS NULL, 'bad_geo_latitude', ''),
|
||||
if(geo_longitude IS NULL, 'bad_geo_longitude', '')
|
||||
]) AS parse_errors
|
||||
FROM stg.geo_raw
|
||||
WHERE click_id IS NOT NULL;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_geo_raw_to_ods_errors
|
||||
TO ods.geo_by_click_errors
|
||||
AS
|
||||
WITH
|
||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude,
|
||||
toFloat64OrNull(JSONExtractString(raw, 'geo_longitude')) AS geo_longitude,
|
||||
arrayFilter(x -> x != '', [
|
||||
if(click_id IS NULL, 'bad_click_id', ''),
|
||||
if(geo_latitude IS NULL, 'bad_geo_latitude', ''),
|
||||
if(geo_longitude IS NULL, 'bad_geo_longitude', '')
|
||||
]) AS parse_errors
|
||||
SELECT
|
||||
ingest_ts,
|
||||
kafka_topic,
|
||||
kafka_partition,
|
||||
kafka_offset,
|
||||
kafka_ts,
|
||||
raw,
|
||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||
FROM stg.geo_raw
|
||||
WHERE length(parse_errors) > 0
|
||||
AND (
|
||||
click_id IS NULL
|
||||
OR geo_latitude IS NULL
|
||||
OR geo_longitude IS NULL
|
||||
);
|
||||
Reference in New Issue
Block a user