feat(ddl): пояс назван явно — в типах колонок и в разборе строки

- Зачем:
  - конвенция #63 записана, а код её не достиг: колонки времени стояли без
    пояса, и сходилось всё лишь потому, что пояс сервера — UTC.
- Что:
  - UTCEventTime объявлен DateTime('UTC'), служебные метки _load_ts и
    kafka_timestamp — DateTime64(3, 'UTC') в STG и ODS.
  - parseDateTimeOrNull получил третьим аргументом 'UTC': маска сверяет
    суффикс Z как букву, зоны из строки не берёт вовсе.
  - контракт схемы и описание выгрузки несут тип с поясом; имя пояса
    Europe/Samara встало рядом со смещением в world.py, сходимость сверяет
    тест.
  - учебный комментарий о линзе — у первой колонки с явным поясом.
- Проверка:
  - make lint, make typecheck, make test (408 тестов)
  - make clean && make up && make check-clickhouse — 9 из 9
  - замер тикета повторён: под session_timezone='Europe/Samara' колонка
    показана 2026-05-31 23:37:00, как и без настроек

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:30:50 +03:00
co-authored by Claude Opus 5
parent e6f300a66e
commit d67e697821
11 changed files with 73 additions and 40 deletions
+6 -5
View File
@@ -218,7 +218,7 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд
`2026-06-01` и разбирается `JSONExtract` без оговорок. `2026-06-01` и разбирается `JSONExtract` без оговорок.
Разбор метки времени идёт Разбор метки времени идёт
`parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ')` `parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ', 'UTC')`
— по буквально названному формату, а не через `parseDateTimeBestEffort`. — по буквально названному формату, а не через `parseDateTimeBestEffort`.
Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо
исключения, то есть годятся в предикат. Выбран точный формат потому, что исключения, то есть годятся в предикат. Выбран точный формат потому, что
@@ -232,10 +232,11 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд
всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не
нужна вовсе, а платится за неё отключённой проверкой. нужна вовсе, а платится за неё отключённой проверкой.
Пояс разбору при #63 добавлен третьим аргументом`'UTC'`; вызов выше приведён Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63. Маска сверяет
без него, каким он был до этого решения. Без имени пояса функция трактует суффикс `Z` как букву и выбрасывает, зоны из строки не берёт вовсе, поэтому без
показания часов по поясу сессии, а тот по умолчанию серверный. Правило целиком имени функция трактует показания часов по поясу сессии, а тот по умолчанию
и его довод — [конвенция часовых поясов](../architecture/storage.md). серверный. Правило целиком и его довод — [конвенция часовых
поясов](../architecture/storage.md).
Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья
модельного дня (день залит дважды) точный формат разобрал метку у каждой, и модельного дня (день залит дважды) точный формат разобрал метку у каждой, и
+20 -16
View File
@@ -104,11 +104,11 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
даёт две: `_timestamp``Nullable(DateTime)`, то есть секунды, и даёт две: `_timestamp``Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms``Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена `_timestamp_ms``Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3))` и заполняется из `_timestamp_ms`: у брокера метка `Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
миллисекундная, соседняя `_load_ts` тоже `DateTime64(3)`, а слой сырья хранит метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от разрядности: хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
брокер метку заполняет не всегда, а необнуляемый тип значил бы либо падение разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
приёма на первом сообщении, либо тихие нули за 1970 год. либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
@@ -124,9 +124,9 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
которого он заведён: повтор доставки в сырье обязан быть виден. которого он заведён: повтор доставки в сырье обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3)`. Ставится она Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
один раз, в матвью приёма, и дальше переносится из STG в ODS как есть: колонка она один раз, в матвью приёма, и дальше переносится из STG в ODS как есть:
отвечает на вопрос «когда строка приехала в хранилище», а не «когда её колонка отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж, самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
@@ -160,10 +160,9 @@ Greenplum, чтобы словарь был общим у двух хранил
разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции: поменяв его. Правило стоит на источнике пояса, а не на функции:
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и `toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок, когда работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
`_load_ts` типизирован. Имя пишется тогда, пишется тогда, когда нужна другая линза, чем у колонки:
когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')` `toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
— это «день по часам счётчика».
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок **Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна, выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
@@ -452,7 +451,7 @@ ODS. Второе: матвью приёма создаётся последне
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении **Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
#37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43 #37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43
(7 августа) и три при обсуждении #63 (8 августа). Все подтвердили то, что здесь (7 августа) и четыре при #63 (8 августа). Все подтвердили то, что здесь
написано. написано.
- Разбор строки берёт пояс у сессии, а не из строки. Под - Разбор строки берёт пояс у сессии, а не из строки. Под
@@ -470,9 +469,14 @@ ODS. Второе: матвью приёма создаётся последне
`2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях `2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях
`2026-06-04`. То есть вывод колонки идёт по поясу сессии, а функция — по `2026-06-04`. То есть вывод колонки идёт по поясу сессии, а функция — по
поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя
одинаково. После объявления `DateTime('UTC')` расхождение уходит: проверено одинаково.
кастом на том же событии — колонка показывает `20:58:56` и при чужом поясе - С объявленным поясом расхождение уходит. Стенд поднят с нуля уже по
сессии. конвенции; событие `WatchID = 384218330540`, `EventDate` = `2026-06-01`:
колонка `DateTime('UTC')` показана `2026-05-31 23:37:00` и без настроек, и
под `session_timezone = 'Europe/Samara'`, по родному клиенту и по HTTP.
`toDate(UTCEventTime)` даёт `2026-05-31`, `toDate(UTCEventTime,
'Europe/Samara')` — `2026-06-01`, `EventDate` — `2026-06-01`: расхождение
`toDate(UTCEventTime)` с `EventDate` осталось, это мир, а не пояс колонки.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту - Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над
+1 -1
View File
@@ -38,7 +38,7 @@
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется | | 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один | | 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая | | 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая |
| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)``EventDate` | | 6 | `UTCEventTime` | `DateTime('UTC')` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)``EventDate` |
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах | | 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет | | 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт | | 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
+1 -1
View File
@@ -99,7 +99,7 @@
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования | | `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
| `CounterID` | UInt32 | константа стенда (один сайт) | | `CounterID` | UInt32 | константа стенда (один сайт) |
| `EventDate` | Date | дата события | | `EventDate` | Date | дата события |
| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики | | `UTCEventTime` | DateTime('UTC') | единственная метка времени, как у Метрики |
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах | | `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` | | `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) | | `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
@@ -107,7 +107,7 @@ COLUMNS: tuple[Column, ...] = (
), ),
Column( Column(
name="UTCEventTime", name="UTCEventTime",
clickhouse_type="DateTime", clickhouse_type="DateTime('UTC')",
numpy_dtype="datetime64[s]", numpy_dtype="datetime64[s]",
normalized_name="utc_event_time", normalized_name="utc_event_time",
group=ColumnGroup.IDENTIFIERS, group=ColumnGroup.IDENTIFIERS,
+9 -5
View File
@@ -16,11 +16,15 @@ from datetime import date
# Счётчик стенда: сайт один, номер — константа мира. # Счётчик стенда: сайт один, номер — константа мира.
COUNTER_ID = 42150607 COUNTER_ID = 42150607
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки # Часовой пояс счётчика — Самара, UTC+4 — записан двумя способами, потому что
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе # стороны просят разное. Генератору нужны минуты: в этом поясе считаются
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором # модельные сутки, как в выгрузке Метрики — `EventDate` дата в поясе счётчика,
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` # `UTCEventTime` абсолютная метка. Отсюда следствие, о котором сторона
# у ночных событий (спека генератора, раздел 9). # хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных
# событий (спека генератора, раздел 9). Хранилищу нужно имя из базы поясов: его
# просят `toDate` и типы колонок DDS (docs/architecture/storage.md, «Часовые
# пояса»).
COUNTER_TIMEZONE = "Europe/Samara"
COUNTER_TIMEZONE_MINUTES = 240 COUNTER_TIMEZONE_MINUTES = 240
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в # D0 — первый день оси модельного времени, понедельник. Реальный календарь в
+1 -1
View File
@@ -31,7 +31,7 @@ NUMPY_BY_CLICKHOUSE_TYPE = {
"String": "object", "String": "object",
"LowCardinality(String)": "object", "LowCardinality(String)": "object",
"Date": "datetime64[D]", "Date": "datetime64[D]",
"DateTime": "datetime64[s]", "DateTime('UTC')": "datetime64[s]",
} }
METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
+10
View File
@@ -6,6 +6,9 @@
«в среднем 34 возврата» и «средняя кука активна 1,9 дня». «в среднем 34 возврата» и «средняя кука активна 1,9 дня».
""" """
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from clickstream_generator import catalog, world from clickstream_generator import catalog, world
@@ -27,6 +30,13 @@ def test_origin_is_a_monday():
assert world.ORIGIN.weekday() == 0 assert world.ORIGIN.weekday() == 0
def test_the_counter_timezone_name_and_offset_say_the_same_thing():
"""Имя пояса просит хранилище, минуты — генератор; расходиться им нельзя."""
midnight = datetime.combine(world.ORIGIN, time())
named = ZoneInfo(world.COUNTER_TIMEZONE).utcoffset(midnight)
assert named == timedelta(minutes=world.COUNTER_TIMEZONE_MINUTES)
def test_weekly_profile_covers_a_week_and_averages_to_one(): def test_weekly_profile_covers_a_week_and_averages_to_one():
assert len(world.WEEKLY_PROFILE_PERCENT) == 7 assert len(world.WEEKLY_PROFILE_PERCENT) == 7
assert sum(world.WEEKLY_PROFILE_PERCENT) == 700 assert sum(world.WEEKLY_PROFILE_PERCENT) == 700
+11 -3
View File
@@ -45,10 +45,18 @@ SETTINGS
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа -- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms — -- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная, -- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
-- _load_ts рядом тоже DateTime64(3), а слой сырья хранит то, что приехало, и -- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не -- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год. -- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
-- --
-- Пояс у обеих меток написан в типе — DateTime64(3, 'UTC'); в DDL стенда он
-- встречается здесь впервые. Само число от пояса не зависит, это секунды от
-- начала эпохи. Пояс — линза: по нему решают, какие часы покажут метку и в
-- какие сутки она попадёт, то есть чем окажется toDate(_load_ts) в ключе
-- партиции ниже. Не назови линзу — её выберет пояс сервера, умолчание, которого
-- в коде не видно. Правило целиком — docs/architecture/storage.md, «Часовые
-- пояса».
--
-- Нарезка и срок жизни — по _load_ts, то есть по реальному времени загрузки: -- Нарезка и срок жизни — по _load_ts, то есть по реальному времени загрузки:
-- модельный день события живёт в ODS, а по нему TTL был бы просто сломан. -- модельный день события живёт в ODS, а по нему TTL был бы просто сломан.
-- Срок — трое суток плюс хвост до суток: куски снимаются целиком -- Срок — трое суток плюс хвост до суток: куски снимаются целиком
@@ -64,9 +72,9 @@ CREATE TABLE IF NOT EXISTS stg.hits_raw_rep ON CLUSTER clickstream_cluster
kafka_topic LowCardinality(String), kafka_topic LowCardinality(String),
kafka_partition UInt64, kafka_partition UInt64,
kafka_offset UInt64, kafka_offset UInt64,
kafka_timestamp Nullable(DateTime64(3)), kafka_timestamp Nullable(DateTime64(3, 'UTC')),
consumer_host LowCardinality(String), consumer_host LowCardinality(String),
_load_ts DateTime64(3) _load_ts DateTime64(3, 'UTC')
) )
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}') ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY toDate(_load_ts) PARTITION BY toDate(_load_ts)
+4 -4
View File
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
ClientID UInt64, ClientID UInt64,
CounterID UInt32, CounterID UInt32,
EventDate Date, EventDate Date,
UTCEventTime DateTime, UTCEventTime DateTime('UTC'),
ClientTimeZone Int16, ClientTimeZone Int16,
EventType LowCardinality(String), EventType LowCardinality(String),
Sign Int8, Sign Int8,
@@ -93,7 +93,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
productQuantity Array(UInt64), productQuantity Array(UInt64),
productEventType Array(String), productEventType Array(String),
ecommerce String, ecommerce String,
_load_ts DateTime64(3) _load_ts DateTime64(3, 'UTC')
) )
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', _load_ts) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', _load_ts)
PARTITION BY EventDate PARTITION BY EventDate
@@ -137,9 +137,9 @@ CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
kafka_topic LowCardinality(String), kafka_topic LowCardinality(String),
kafka_partition UInt64, kafka_partition UInt64,
kafka_offset UInt64, kafka_offset UInt64,
kafka_timestamp Nullable(DateTime64(3)), kafka_timestamp Nullable(DateTime64(3, 'UTC')),
consumer_host LowCardinality(String), consumer_host LowCardinality(String),
_load_ts DateTime64(3) _load_ts DateTime64(3, 'UTC')
) )
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}') ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY toDate(_load_ts) PARTITION BY toDate(_load_ts)
+9 -3
View File
@@ -52,6 +52,12 @@
-- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005, -- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005,
-- «Что проверено». -- «Что проверено».
-- --
-- Третьим аргументом назван пояс — 'UTC'. Суффикс Z маска сверяет как букву и
-- выбрасывает, зоны из строки не берёт вовсе, поэтому без имени функция читала
-- бы показания часов по поясу сессии, а тот по умолчанию серверный. Тип
-- колонки этого не чинит: он про то, как число покажут, а не какое ляжет.
-- Правило и замер — docs/architecture/storage.md, «Часовые пояса».
--
-- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и -- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и
-- JSONExtract её берёт. -- JSONExtract её берёт.
@@ -79,7 +85,7 @@ WITH
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ') IS NOT NULL AS key_fields_parsed '%Y-%m-%dT%H:%i:%SZ', 'UTC') IS NOT NULL AS key_fields_parsed
SELECT SELECT
JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID, JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID,
JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID, JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID,
@@ -88,7 +94,7 @@ SELECT
JSONExtract(raw, 'EventDate', 'Date') AS EventDate, JSONExtract(raw, 'EventDate', 'Date') AS EventDate,
assumeNotNull(parseDateTimeOrNull( assumeNotNull(parseDateTimeOrNull(
JSONExtractString(raw, 'UTCEventTime'), JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ')) AS UTCEventTime, '%Y-%m-%dT%H:%i:%SZ', 'UTC')) AS UTCEventTime,
JSONExtract(raw, 'ClientTimeZone', 'Int16') AS ClientTimeZone, JSONExtract(raw, 'ClientTimeZone', 'Int16') AS ClientTimeZone,
JSONExtract(raw, 'EventType', 'String') AS EventType, JSONExtract(raw, 'EventType', 'String') AS EventType,
JSONExtract(raw, 'Sign', 'Int8') AS Sign, JSONExtract(raw, 'Sign', 'Int8') AS Sign,
@@ -173,7 +179,7 @@ WITH
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ') IS NOT NULL AS key_fields_parsed '%Y-%m-%dT%H:%i:%SZ', 'UTC') IS NOT NULL AS key_fields_parsed
SELECT SELECT
raw, raw,
multiIf( multiIf(