Files
clickstream-data-platform/sql/ddl/30-ods-views.sql
T
ddadmin 75f28fe99f docs(ingestion): упрощены объяснения источников и приема данных
Зачем: помочь начинающему инженеру проследить событие и версию заказа
от генератора до хранилища, включая ошибки и повторные поступления.

Что: обновлены вводная генератора, справочники приема и модели заказа,
разделы хранилища и комментарии SQL и дага. Сохранены технические условия;
исполняемый код, схемы и поведение стенда не изменены.

Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI.
SQL без комментариев и Python AST без строк документации совпали
с исходными. Генерация данных и сброс состояния не выполнялись.

Refs #250
2026-09-13 16:32:45 -04:00

172 lines
10 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Актуальные события: FINAL выбирает последнюю доставку по _load_ts,
-- не дожидаясь фоновых слияний. Поля и типы остаются как в ods.event_dist.
CREATE VIEW IF NOT EXISTS ods.event_v ON CLUSTER clickstream_cluster
AS
SELECT *
FROM ods.event_dist FINAL;
-- ODS: каноническое чтение текущего состояния заказов.
--
-- То же, что у событий, но версию здесь задаёт источник: физическая таблица
-- хранит все приехавшие версии заказа, а FINAL оставляет приехавшую
-- позднейшим слепком — колонка версии здесь snapshot_date. Читать заказы
-- голым SELECT по _dist значит зависеть от того, сколько фоновых слияний
-- успело пройти.
--
-- Поля остаются языком источника, обогащения нет: DDS читает это
-- представление и отдельно решает, какой будет его модель (ADR 0010).
-- Способ выбора версии можно заменить, не трогая потребителей, — за именем
-- скрыт FINAL, а не устройство.
CREATE VIEW IF NOT EXISTS ods.order_v ON CLUSTER clickstream_cluster
AS
SELECT *
FROM ods.order_dist FINAL;
-- Разбор STG в события и ошибки. Два представления используют одинаковое
-- условие годности; второе берет его отрицание. Условие дает только 0 или 1:
-- NULL не выбрала бы ни одна ветвь. Функции разбора не вызывают исключений
-- на некорректном raw, чтобы одно сообщение не прервало прием.
--
-- Оба представления срабатывают на вставку в stg.hits_raw_dist,
-- до распределения по шардам. Порядок SELECT совпадает с целевой таблицей.
--
-- Прием проверяет объект JSON, точный набор ключей и разбор пяти полей:
-- WatchID, VisitID, ClientID, EventDate, UTCEventTime. Остальные поля
-- извлекаются по формату без отдельной проверки. Граница — ADR 0005.
-- arraySort с обеих сторон позволяет сравнить ключи независимо от порядка.
--
-- UTCEventTime приходит строкой вида "2026-06-01T12:34:56Z". Ее разбирает
-- parseDateTimeOrNull с явной маской: JSONExtract в DateTime такую форму
-- не принимает, а BestEffort допускает неполные даты. Пояс UTC указан
-- отдельно, потому что Z в маске проверяется как буква. EventDate приходит
-- как "2026-06-01" и разбирается через JSONExtract.
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster
TO ods.event_dist
AS
WITH
JSONType(raw) = 'Object' AS is_object,
arraySort(JSONExtractKeys(raw)) = arraySort([
'WatchID', 'VisitID', 'ClientID', 'CounterID', 'EventDate',
'UTCEventTime', 'ClientTimeZone', 'EventType', 'Sign',
'URL', 'Referer', 'Title', 'UTMSource', 'UTMMedium', 'UTMCampaign',
'UTMContent', 'UTMTerm', 'LastTrafficSource', 'HasGCLID', 'YCLID',
'Browser', 'BrowserMajorVersion', 'BrowserLanguage', 'OperatingSystem',
'OperatingSystemRoot', 'DeviceCategory', 'MobilePhoneModel',
'ScreenWidth', 'ScreenHeight', 'IPAddress', 'RegionCountry',
'RegionCity', 'RegionCountryID', 'RegionCityID',
'GoalsReached', 'ParsedParamsKey1',
'purchaseID', 'purchaseRevenue', 'purchaseCurrency', 'purchaseCoupon',
'productID', 'productName', 'productCategory', 'productPrice',
'productQuantity', 'productEventType', 'ecommerce'
]) AS keys_match,
JSONExtract(raw, 'WatchID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'VisitID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ', 'UTC') IS NOT NULL AS key_fields_parsed
SELECT
JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID,
JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID,
JSONExtract(raw, 'ClientID', 'UInt64') AS ClientID,
JSONExtract(raw, 'CounterID', 'UInt32') AS CounterID,
JSONExtract(raw, 'EventDate', 'Date') AS EventDate,
assumeNotNull(parseDateTimeOrNull(
JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ', 'UTC')) AS UTCEventTime,
JSONExtract(raw, 'ClientTimeZone', 'Int16') AS ClientTimeZone,
JSONExtract(raw, 'EventType', 'String') AS EventType,
JSONExtract(raw, 'Sign', 'Int8') AS Sign,
JSONExtract(raw, 'URL', 'String') AS URL,
JSONExtract(raw, 'Referer', 'String') AS Referer,
JSONExtract(raw, 'Title', 'String') AS Title,
JSONExtract(raw, 'UTMSource', 'String') AS UTMSource,
JSONExtract(raw, 'UTMMedium', 'String') AS UTMMedium,
JSONExtract(raw, 'UTMCampaign', 'String') AS UTMCampaign,
JSONExtract(raw, 'UTMContent', 'String') AS UTMContent,
JSONExtract(raw, 'UTMTerm', 'String') AS UTMTerm,
JSONExtract(raw, 'LastTrafficSource', 'String') AS LastTrafficSource,
JSONExtract(raw, 'HasGCLID', 'UInt8') AS HasGCLID,
JSONExtract(raw, 'YCLID', 'UInt64') AS YCLID,
JSONExtract(raw, 'Browser', 'String') AS Browser,
JSONExtract(raw, 'BrowserMajorVersion', 'UInt16') AS BrowserMajorVersion,
JSONExtract(raw, 'BrowserLanguage', 'String') AS BrowserLanguage,
JSONExtract(raw, 'OperatingSystem', 'String') AS OperatingSystem,
JSONExtract(raw, 'OperatingSystemRoot', 'String') AS OperatingSystemRoot,
JSONExtract(raw, 'DeviceCategory', 'UInt8') AS DeviceCategory,
JSONExtract(raw, 'MobilePhoneModel', 'String') AS MobilePhoneModel,
JSONExtract(raw, 'ScreenWidth', 'UInt16') AS ScreenWidth,
JSONExtract(raw, 'ScreenHeight', 'UInt16') AS ScreenHeight,
JSONExtract(raw, 'IPAddress', 'String') AS IPAddress,
JSONExtract(raw, 'RegionCountry', 'String') AS RegionCountry,
JSONExtract(raw, 'RegionCity', 'String') AS RegionCity,
JSONExtract(raw, 'RegionCountryID', 'UInt32') AS RegionCountryID,
JSONExtract(raw, 'RegionCityID', 'UInt32') AS RegionCityID,
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
-- purchaseRevenue приходит числом рублей и разбирается сразу в Decimal.
-- Прямой разбор сверяли через Context7 и на ClickHouse 26.3 (#155).
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
JSONExtract(raw, 'productID', 'Array(String)') AS productID,
JSONExtract(raw, 'productName', 'Array(String)') AS productName,
JSONExtract(raw, 'productCategory', 'Array(String)') AS productCategory,
JSONExtract(raw, 'productPrice', 'Array(Int64)') AS productPrice,
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
-- _load_ts остается временем исходной доставки и версией события.
_load_ts
FROM stg.hits_raw_dist
WHERE is_object AND keys_match AND key_fields_parsed;
-- Ошибки: отрицание того же условия годности.
-- Класс определяется первым нарушенным условием. Например, JSON-строка
-- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
-- Условие повторено рядом с запросом, без отдельной функции ClickHouse.
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster
TO ods.event_errors_dist
AS
WITH
JSONType(raw) = 'Object' AS is_object,
arraySort(JSONExtractKeys(raw)) = arraySort([
'WatchID', 'VisitID', 'ClientID', 'CounterID', 'EventDate',
'UTCEventTime', 'ClientTimeZone', 'EventType', 'Sign',
'URL', 'Referer', 'Title', 'UTMSource', 'UTMMedium', 'UTMCampaign',
'UTMContent', 'UTMTerm', 'LastTrafficSource', 'HasGCLID', 'YCLID',
'Browser', 'BrowserMajorVersion', 'BrowserLanguage', 'OperatingSystem',
'OperatingSystemRoot', 'DeviceCategory', 'MobilePhoneModel',
'ScreenWidth', 'ScreenHeight', 'IPAddress', 'RegionCountry',
'RegionCity', 'RegionCountryID', 'RegionCityID',
'GoalsReached', 'ParsedParamsKey1',
'purchaseID', 'purchaseRevenue', 'purchaseCurrency', 'purchaseCoupon',
'productID', 'productName', 'productCategory', 'productPrice',
'productQuantity', 'productEventType', 'ecommerce'
]) AS keys_match,
JSONExtract(raw, 'WatchID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'VisitID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ', 'UTC') IS NOT NULL AS key_fields_parsed
SELECT
raw,
multiIf(
NOT is_object, 'not_an_object',
NOT keys_match, 'keyset_mismatch',
'key_field_unparsed'
) AS error_class,
kafka_topic,
kafka_partition,
kafka_offset,
kafka_timestamp,
consumer_host,
_load_ts
FROM stg.hits_raw_dist
WHERE NOT (is_object AND keys_match AND key_fields_parsed);