"""Канонический сериализатор: единственное место, где событие целиком → JSON. Правило «сериализатор один» (спека генератора, разделы 4 и 6) — не про экономию строк, а про канон: два прогона одного дня обязаны дать те же байты, а байты рождаются здесь. Второе место, собирающее событие руками, разошлось бы с этим по экранированию, порядку ключей или записи чисел — и разошлось бы молча. Граница правила проходит по событию, а не по всякому JSON: вложенный блок `ecommerce` собирает `commerce`, и это часть содержимого колонки, а не второй сериализатор. Что делает канон: - **Порядок ключей — порядок контракта схемы.** Он берётся из `schema.COLUMNS` и нигде не повторяется: два источника порядка разъехались бы при первой же вставке колонки. - **Все 47 ключей всегда.** Пусто по контракту — пустое значение: пустой массив, пустая строка, ноль. Пропавший ключ увёл бы событие в брак целиком: строгий приём хранилища сверяет набор ключей (ADR 0005). - **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как `2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие глазами, а число эпохи этот урок убивает. - **Одно событие — один документ JSON**, без перевода строки внутри: приёмник сам решает, чем их разделить. Колонки переводятся в питоновские значения целиком, а не по строкам: numpy делает это одним вызовом на колонку, и на дне в полсотни тысяч событий разница заметна. Обратная сторона — день лежит в памяти дважды; проигрыватель поэтому и берёт его днями, а не горизонтом целиком. """ from typing import Any import numpy as np import orjson from numpy.typing import NDArray from clickstream_generator import schema from clickstream_generator.day import Day _ARRAY_PREFIX = "Array(" def events(day: Day, limit: int | None = None) -> list[bytes]: """Канонические байты событий дня: по документу JSON на событие. `limit` берёт первые события дня и на этом останавливается — срез для того, кто смотрит на конвейер и не хочет ждать целый день (спека, раздел 9). Ограничение считается до сериализации: платить за то, что не поедет, незачем. """ count = len(day) if limit is None else min(limit, len(day)) names = tuple(column.name for column in schema.COLUMNS) values = [ _values(column, day.columns[column.name][:count]) for column in schema.COLUMNS ] return [ orjson.dumps(dict(zip(names, row, strict=True))) for row in zip(*values, strict=True) ] def _values(column: schema.Column, values: NDArray[Any]) -> list[Any]: """Колонка питоновскими значениями — в той записи, в какой уедет на провод. Массив узнаётся по типу ClickHouse, а не по `numpy_dtype`: у колонки-массива там записан тип элемента (`uint32`), и от скалярной колонки её этим не отличить. """ if column.clickhouse_type.startswith(_ARRAY_PREFIX): # Колонка-массив: в ячейке лежит свой массив, пустой у события, # которому эта колонка не по смыслу. return [cell.tolist() for cell in values] if column.numpy_dtype == "datetime64[D]": return np.datetime_as_string(values, unit="D").tolist() if column.numpy_dtype == "datetime64[s]": return np.datetime_as_string(values, unit="s", timezone="UTC").tolist() return values.tolist()