Перейти к содержанию

ClickHouse: что работает иначе

В ClickHouse нет частей SQL, на которые hare иначе опирался бы: ограничений уникальности и внешних ключей, UPDATE на месте, RETURNING, ON CONFLICT, блокировок строк, числа строк, изменённых записью. Транзакции и генерируемые ключи есть только на сервере с ClickHouse Keeper. Каждая строка ниже говорит, что делает hare: где у ClickHouse есть другая запись того же SQL, hare пишет её; где в языке её нет, вызов отклоняется до отправки SQL.

NullIf() требует тип результата, который ClickHouse может обернуть в Nullable. Для массивов, кортежей, map, nested, Dynamic, Variant и других типов без такой обёртки вызов поднимает UnSupportedError до SQL. Dynamic и Variant сами могут содержать null, но NULLIF в ClickHouse всё равно требует неподдерживаемую обёртку Nullable.

Чего нет в ClickHouse Что делает hare
Транзакций по умолчанию atomic() даёт UnSupportedError, если подключение не просит их через transactions=true, — см. Транзакции и блокировки. Без них собственные многокомандные записи hare идут одна за другой, и сбой оставляет записанными команды до него.
Точек сохранения Вложенный atomic() присоединяется к внешней транзакции; ошибка, покинувшая его, откатывает всю транзакцию.
Генерируемых ключей С ClickHouse Keeper ключи берутся из серии до записи строк (generateSerialID) — см. Модели. Без него модель, ключ которой генерирует база, отклоняется при привязке.
Ограничений уникальности и внешних ключей Уникальность или связь с db_constraint=True отклоняется — у модели при привязке (ConfigurationError), у миграции, объявляющей уникальность, до её первой команды (UnSupportedError); см. Модели.
CHECK, пропускающего строку, условие которой даёт NULL ClickHouse такую строку отвергает (IntegrityError): CheckConstraint(check=Q(price__gte=0)) отвергает price=None — проверка, пропускающая NULL, называет его: Q(price__isnull=True) \| Q(price__gte=0). Проверка, которую добавляет миграция, проверяет только строки, записанные позже, — см. Операции.
UPDATE строк на месте save(), QuerySet.update() и bulk_update() выполняют мутацию ALTER TABLE ... UPDATE, завершённую до возврата из вызова — см. UPDATE строк на месте
Числа строк, изменённых UPDATE или DELETE update(), delete() и bulk_update() возвращают None (Features.reports_written_row_count) — см. Число записанных строк
RETURNING returning() у update() или delete() и bulk_create(returning=True) дают UnSupportedError; значения, которые сервер дал записанной строке, остаются непрочитанными в экземпляре — см. RETURNING
ON CONFLICT bulk_create() с ignore_conflicts, update_fields, on_conflict или on_conflict_constraint даёт UnSupportedError (Features.supports_insert_conflicts); transactional outbox, который так пишет свои события, отклоняется при привязке его модели — см. Outbox.
Батчей у UPDATE BackfillColumn заполняет все строки одной мутацией, batch_size не используется: мутация не блокирует строк, которые пощадил бы батч, и каждая переписывает изменяемые части.
Блокировок строк select_for_update() и atomic(lock_timeout=...) дают UnSupportedError — см. Транзакции и блокировки.
NULL от агрегата по пустому набору Агрегат, результат которого на пустом наборе был бы значением, которого нет ни в одной строке, пишется в форме -OrNull (maxOrNull(...)) и даёт None; Sum даёт собственный 0 ClickHouse (None для поля с null=True) — список см. в NULL от агрегата по пустому набору
LIKE ... ESCAPE Шаблон экранируется обратной косой чертой — собственным символом экранирования ClickHouse. Пустой текст содержится в любом тексте, как в PostgreSQL (contains="", contains=F("name") при пустом имени): ClickHouse не сопоставляет пустую строку шаблону '%%', поэтому hare пишет такой шаблон как '%'.
Простого UPDATE и отказа в NULL в готовом SQL Ваш SQL (RunSQL, raw(), execute()) выполняется так, как его принимает ClickHouse: UPDATE нужна настройка enable_block_number_column (ALTER TABLE ... UPDATE — нет), а NULL в колонке не Nullable пишется пустым значением типа (0, ''). Записи hare такой None отвергают.
Обратной косой черты как обычного символа имени в кавычках Имя с обратной косой чертой — таблицы, колонки, псевдонима — пишется с удвоенной чертой: одиночную ClickHouse читает как экранирование.
Рекурсивного запроса, добавляющего строки через UNION DISTINCT with_recursive() нужен max_depth, который заканчивает цикл в строках, — см. Рекурсивные запросы и CTE. Без него обход даёт UnSupportedError.
NULL первым при сортировке по убыванию ClickHouse ставит NULL последним в обоих направлениях — см. NULL первым при сортировке по убыванию
Ключ с частью NULL в __in Q(relation__in=[...]) связи с составным ключом сравнивает кортеж колонок: для строки, ключ которой содержит NULL, ClickHouse даёт ложь там, где SQLite и PostgreSQL дают NULL, — filter() берёт те же строки, а условие в аннотации (annotate(x=Q(...))) держит False, а не None
Курсора транзакции stream() читает строки и вне транзакции: сервер отдаёт их по мере вычисления. Ранний выход из async for закрывает поток и возвращает соединение.
Смещения зоны от UTC с секундами Читается без секунд — см. Смещения зоны от UTC с секундами.

Смещения зоны от UTC с секундами

ClickHouse хранит смещение зоны в целых минутах: местное среднее время до стандартного времени зоны (Москва до 1919 года, +02:30:17) читается без секунд — Extract("second"), Trunc() до суток и длиннее и __time такого момента в этой зоне (или при use_timezone=False в ней) отличаются от PostgreSQL на эти секунды; Trunc() до часа, минуты или секунды берёт смещение целиком.

UPDATE строк на месте

save(), QuerySet.update() и bulk_update() выполняют мутацию ALTER TABLE ... UPDATE, завершённую до возврата из вызова. Таблица с ClickhouseTableOptions(lightweight_updates=True) получает вместо неё лёгкий UPDATE — см. Модели. delete() выполняет лёгкий DELETE, а если его условие читает union()/intersection()/difference(), — мутацию ALTER TABLE ... DELETE: лёгкий DELETE сервера на таком условии падает. Упавшая мутация останавливается (KILL MUTATION) до того, как вызов выдаст ошибку: иначе сервер повторял бы её, из-за неё падала бы каждая следующая мутация таблицы, и она ещё могла бы примениться после ошибки вызова. Мутация не выполняет связанных подзапросов: update(), который записывает в поле Subquery с OuterReference, фильтруется или удаляет по такому подзапросу, даёт UnSupportedError. Начиная с ClickHouse 26.7 сервер отклоняет мутацию, которая записывает в поле без NULL выражение, способное быть NULL, — Subquery поля с null=True (CANNOT_INSERT_NULL_IN_ORDINARY_COLUMN), — даже когда его значение не NULL; такое выражение оберните в Coalesce() со значением. Мутация bulk_update() пишет не больше 1000 строк (Features.max_bulk_update_rows): значения каждой строки она находит перебором списка строк, поэтому более длинный список дороже на строку и выходит за предел AST сервера. Ключи её строк читает подзапрос самого списка, одинаковый на каждой реплике: она выполняется с allow_nondeterministic_mutations = 1, без чего таблица ReplicatedMergeTree не принимает мутацию с подзапросом, — update(), отфильтрованный подзапросом другой таблицы, сервер там отвергает, ведь реплики могут прочитать разные строки.

Одновременные мутации одной таблицы — собственное поведение сервера: каждая читает строки такими, какими они были при её начале, поэтому два одновременных update(n=F("n") + 1) одной строки могут оба записать n + 1 — одно приращение теряется, тогда как блокировка строки в PostgreSQL выполнила бы их по очереди. Выполняйте такие обновления одной строки последовательно.

Число записанных строк

ClickHouse не сообщает, сколько строк изменила мутация или лёгкий UPDATE/DELETE, поэтому update(), delete() и bulk_update() возвращают None; чтобы узнать, что изменилось, прочитайте строки. Удаление, которое hare выполняет сам, — каскад в Python, модель с переопределённым delete() — считает прочитанные для него строки. Без числа строк:

  • save() или delete() строки, которую удалил другой клиент, не обнаруживается — DoesNotExist не возникает;
  • модель с Meta.optimistic_lock_field отклоняется при привязке: устаревшая версия осталась бы незамеченной;
  • bulk_update() считает записанным каждый объект и применяет ко всем его версию, снимок изменённых полей и значения auto_now.

add()/set() обратной связи вместо этого сначала читает строки — строка, которую скрывает область по умолчанию связанной модели (чужого арендатора, мягко удалённая), отклоняется IntegrityError до записи, как и на других базах.

RETURNING

update().returning(), delete().returning() и bulk_create(returning=True) дают UnSupportedError до отправки SQL. Значение, которое сервер даёт строке, записанной save() или create(), — db_default, колонка MATERIALIZED — остаётся в экземпляре непрочитанным: поле держит DatabaseDefault, вычисляемое поле не загружено, а refresh_from_db() читает их. Ключ, который генерирует сервер, берётся до вставки (см. Модели); модель, ключ которой оставлен db_default, отклоняется при записи строки.

NULL от агрегата по пустому набору

Max, Min, Avg, StdDev, Variance и собственные агрегаты ClickHouse ArgMax, ArgMin, AnyValue, AnyLast, Quantile и Median (и с exact=True) пишутся в форме -OrNull (maxOrNull(...)), поэтому на пустом наборе дают None, а не значение типа колонки по умолчанию; Count даёт 0, а Sum — собственный 0 ClickHouse (0.00 для десятичного; None для поля с null=True, и на наборе только из NULL тоже). В запросе с GROUP BY в группе есть хотя бы одна строка, поэтому агрегат без _filter остаётся в простой и более быстрой форме (max(...)). Выборочное отклонение по одному значению — None, как в SQL, а не NaN ClickHouse.

Результаты самого ClickHouse

hare не заставляет ClickHouse давать результат PostgreSQL. Она проверяет то, что гарантирует сама, — значение, которое записывает, член перечисления, — пишет формы, которые хранит сама (перечисление по метке, адрес как IPv6, путь JSON), и выполняет свои функции собственными средствами ClickHouse; всё остальное — результат самого ClickHouse, и он отличается от результата PostgreSQL:

  • Деление и остаток от деления на ноль дают inf или nan для float и собственную ошибку ClickHouse для целых; математическая функция вне области определения (Sqrt(-1), Ln(0)) даёт nan или inf. hare не проверяет ничего из этого. MOD и отрицательный ноль — тоже собственные.
  • Арифметика десятичных — собственная ClickHouse. У частного столько знаков после запятой, сколько у делимого, лишние отсекаются, а не округляются (F("price") / 8 от 12.34 — 1.54). Сумма или произведение сверх цифр своего типа «заворачивается» (Decimal128 сверх 38 цифр), десятичное рядом с целым сохраняет собственный тип (Decimal(9, 4) плюс большое Int64 «заворачивает» цифры целого), а произведение сверх цифр типа результата даёт ошибку ClickHouse Decimal math overflow. Avg(), StdDev() и Variance() десятичных — числа с плавающей точкой (Features.computes_decimal_statistics_exactly); Sum() десятичных — Decimal(38, знаки), квантиль остаётся десятичным. Round() — это round() ClickHouse. Значение, которое сервер вычислил шире 38 цифр (Sum() от Decimal128 сверх них), clickhouse-driver читает со всеми цифрами, а clickhouse-connect при чтении округляет до 38 цифр — это поведение самой библиотеки.
  • Количества, длины, номера строк и ранги (Count(), Length(), StrIndex() и оконные функции RowNumber(), Rank(), DenseRank(), NTile()) — знаковые Int64: собственный UInt64 ClickHouse не находит общего типа со знаковым целым в Coalesce(), Case() и Greatest().
  • Целочисленная арифметика — собственная ClickHouse: результат за пределами диапазона своего типа «заворачивается» (9223372036854775807 + 1 — это -9223372036854775808), как и значение, которое записывает update(n=F("n") + 1) (2147483647 + 1 хранится как -2147483648 в Int32), и Sum() и Avg() целых, которые суммируются в типе колонки (Avg() двух значений UInt64 2**64 - 1 — это 2**63); Abs() минимума типа — сам минимум. / двух целых даёт точное частное, в том числе дробь (Features.divides_integers_exactly), — поле вывода выражения FloatField. Десятичное, которое выражение вычисляет с большим числом знаков, чем у поля, ClickHouse отсекает, а не округляет: update(price=F("price") + Decimal("0.005")) оставляет 12.34, где PostgreSQL и SQLite пишут 12.35.
  • Cast() — собственное преобразование ClickHouse. К целому оно отбрасывает дробную часть, тогда как PostgreSQL округляет, и «заворачивает» число за пределами диапазона цели (Cast(Value(3e9), IntField()) — это -2147483648); к десятичному отсекает знаки сверх знаков цели, float преобразуется как то двоичное число, которым он является (2.675 становится 2.67). Текст читают парсеры ClickHouse: текст, не являющийся числом, — NULL, как и число, обрамлённое пробелами (' 42 '); дату или момент читает parseDateTime64BestEffort() — дата без части дополняется ("2024-01" — первое января), время за пределами суток переносится ("25:00" — час ночи следующего дня), год вне 1900–2299 становится ближайшим днём, который хранит ClickHouse, — начиная с ClickHouse 26.7, где DateTime64 хранит годы с 1 по 9999, такой год читается как написан, а дата такого года после 2299 — это день Date32, который clickhouse-driver не читает (его собственный KeyError), — а дня, которого нет в календаре ("2023-02-29"), даёт исключение. Приведение к логическому — toBool() ClickHouse, который принимает и yes, no, on, off и даёт исключение на любом другом тексте. Приведение к тексту — toString(): 1e308, -inf, десятичное без хвостовых нулей, момент в виде 2024-01-02 03:04:05.678900. max_length у CharField ничего не обрезает.
  • Сдвиг момента на длительность — собственный addMicroseconds() ClickHouse: момент за пределами лет, хранимых колонкой, становится ближайшим из тех, что она хранит, — DateTime с 1900-01-01 по 2299-12-31 23:00 (DateTime64 начиная с ClickHouse 26.7 — годы с 1 по 9999), Date32 — до 2299-12-31.
  • union(), intersection() и difference() упорядоченных ветвей не оставляют дублей: подключение работает с выключенной настройкой ClickHouse optimize_distinct_in_order, которая оставляла дубли таких ветвей, — в том числе внутри подзапроса, собственные SETTINGS которого операция над множествами не читает.
  • Concat() склеивает текст, который ClickHouse даёт значению колонки (toString()); наивное дата-время (use_timezone=False) пишется по местному поясу машины. Значение Python, переданное литералом, — это его текст в Python: логическое true или false, float через repr() (1000000000000000.0), дата или дата-время — текст ISO. Текстовый оператор фильтра (contains, startswith, iexact, ...) для колонки логического, float или десятичного типа сопоставляет текст, который ClickHouse даёт значению.
  • Смена регистра. Upper(), Lower() и операторы фильтра i... идут через upperUTF8() и lowerUTF8() — собственные функции сервера; операторы i... сравнивают текст в верхнем регистре, поэтому iexact="ß" находит и SS, и ss (upperUTF8('ß') — это SS).
  • Substr, Chr и Ord — это substringUTF8() (начало 0 отклоняется, отрицательное начало считается с конца, отрицательная длина оставляет символы с конца), char() (кодовая точка, которой не соответствует ни один символ, отклоняется, 0 даёт \x00) и кодовая точка первого символа для Ord. У текстовых функций нет 32-битного предела длины, позиции и количества, они принимают выражение любого типа; литерал не того типа отклоняется.
  • Lag/Lead с JSON-default — массивом значений разных типов ([1, 2.5, None, "x"]) — через HTTP-драйвер падают с NO_COMMON_TYPE, хотя CAST того же JSON проходит: так работают оконные функции самого сервера (проверено на ClickHouse 25.8.33.6).
  • FIRST_VALUE и LAST_VALUE — собственные функции ClickHouse, как и порядок UUID (сначала младшие 64 бита: ключ uuid7 не сортируется по времени, сортируйте по колонке с датой) и адресов IPv6. Перечисление упорядочивается по метке: так его хранит hare. Время суток упорядочивается и сравнивается как хранимый текст: 10:00:00+03:00 идёт после 08:00:00+00:00, хотя это более ранний момент.
  • NaN в FloatField хранится. Нулевой байт в тексте даёт ValidationError — значение пишется в текст SQL, который его не вмещает; внутри объекта JSON он хранится так, как его хранит ClickHouse.
  • Ошибки. Перевод колонки в NOT NULL, пока в ней есть NULL, даёт column X of table Y holds NULL.

NULL первым при сортировке по убыванию

ClickHouse ставит NULL последним в обоих направлениях; hare пишет сортировку по убыванию как DESC NULLS FIRST, так что NULL встаёт последним по возрастанию и первым по убыванию, и страницы после курсора, reverse() и last() не теряют строк. ValueRange окна со смещением сортирует по значению как по Float64, а NULL — как по бесконечности с той стороны, куда встаёт NULL: собственный диапазон ClickHouse вокруг NULL захватывает другие строки. ArrayAgg(order_by=...) ставит NULL в значении сортировки туда, куда его ставит сортировка.

Длинные списки __in

Сервер медленно разбирает список литералов: 100 000 значений занимают почти секунду. Список __in или __not_in из 500 и более значений одного простого типа — UUID, целых, текстов, дат или строк из них для ключа из нескольких колонок — передаётся одним параметром: чтение отправляет значения рядом с командой внешней таблицей (x IN hare_set_1), которую сервер читает как данные за несколько миллисекунд, а любая другая команда (мутация, DELETE), как и чтение в транзакции, которая не читает внешних таблиц, пишет их списком литералов. SQL запроса один и тот же при любой длине списка, поэтому у него один план.

Индексы — это индексы пропуска данных: Index(fields=...) создаёт индекс minmax с гранулярностью 1, а остальные типы есть в hare.dialects.clickhouse.indexes — см. Модели. hare inspectdb и hare drift читают таблицы, колонки, первичные ключи, комментарии, индексы пропуска, опции таблиц, представления и словари из таблиц system.

Связанные подзапросы

ClickHouse выполняет связанные подзапросы, но часть из них считает неверно или отклоняет:

  • Связанный подзапрос внутри IN/NOT IN ClickHouse 25.8, 25.10, 25.12, 26.3, 26.7, 26.8 и 26.9 отклоняет с UnSupportedError от сервера, даже когда связанный скалярный подзапрос выполняется.
  • EXISTS, связанный равенством колонок — и строки колонок составного ключа, — пишется как (columns) IN (SELECT ...) — собственный связанный EXISTS сервер не выполняет в ON соединения и над UNION DISTINCT (25.8). EXISTS над union() пишется как EXISTS любой из частей, каждая так же. Связанный в своём WHERE не только равенствами (version__gt=OuterReference("version"), как LatestVersions) выполняется как собственный EXISTS сервера; в UPDATE или DELETE или связанный вне своего WHERE он даёт UnSupportedError. Форма, которую сервер пока не выполняет, даёт UnSupportedError с его сообщением.
  • Группировка по связанному подзапросу (values("author_name").annotate(...) над Subquery с OuterReference) даёт UnSupportedError — сервер по таким не группирует.
  • Подзапрос, который сам сортирует или срезает свои строки (Subquery(... .order_by("-created")[:1]) с OuterReference), даёт UnSupportedError — сервер его отклоняет.
  • Подзапрос, выбирающий Count, Sum или другой агрегат своих строк и связанный по колонке, общей у строк внешнего запроса (Book.objects.annotate(n=Subquery(Award.objects.filter(author=OuterReference( "author"))...))), даёт UnSupportedError (Features.supports_correlated_aggregates): сервер читает каждую его строку столько раз, сколько внешних строк держат то же значение, и столько же раз её считает. Связанный по первичному ключу или уникальному полю внешней модели (OuterReference("pk")) он работает, как и Max, Min и Avg по любой колонке. Считайте через связь — annotate(Count("books")).
  • Подзапрос в ORDER BY или выбранный рядом с WHERE вычисляется в производной таблице: строки выбираются без WHERE, условия и ключи сортировки становятся её скрытыми колонками, а внешний запрос фильтрует, сортирует и срезает их. SQL длиннее, результат — тот, что просит запрос.
  • UPDATE или DELETE, в условии которого есть связанный подзапрос, даёт UnSupportedError — мутация их не выполняет.
  • OuterReference через связь (OuterReference("author__name")) читает колонку таблицы, присоединённой к запросу вокруг подзапроса, а по таким сервер не связывает: это UnSupportedError. Пройдите связь внутри подзапроса и свяжите его колонкой самой внешней таблицы (Author.objects.filter(books__id=OuterReference("id"))) или прочитайте поле связи без подзапроса (F("author__name")).
  • Таблица, которую подзапрос присоединяет, читается через производную таблицу, как и его собственная: в ClickHouse таблица с псевдонимом отзывается и на своё имя, и таблица подзапроса забрала бы имя внешнего запроса.

Ошибки версии сервера

hare не обходит ошибку версии ClickHouse; остаётся собственная ошибка или собственный результат сервера:

  • Колонка, ставшая Nullable (AlterField на null=True): ClickHouse до 26.3 читает карту NULL, которой нет у частей, сохранённых до изменения, как NULL во всех значениях — filter(title__isnull=True) находит все такие строки. На этих версиях перепишите колонку после миграции сами: ALTER TABLE "article" UPDATE "title" = "title" WHERE 1 (в RunSQL после AlterField). До этого так же читают и проверки следующих миграций колонки: откат изменения к NOT NULL отклоняется с IntegrityError (holds NULL), а значение такой части, которое не переводится в новый тип при смене типа, до изменения не находится.
  • Сортировка по пути JSON (order_by("data__n")): ClickHouse 25.8 с allow_simdjson=0, который hare ставит, чтобы читать целые больше 64 бит, в зависимости от данных может поставить некоторые строки колонки JSON не на своё место. Там сортируйте по приведению пути (Cast("data__n", IntField())).
  • update(), ставящий константу под отрицанием, которое сворачивается в константу (exclude(owner__isnull=True).update(balance=0) при owner без NULL): ClickHouse до 26.3 отказывает с Cannot convert column ... because it is non constant in source stream.
  • ClickHouse 25.12 отказывает запросу, который соединяет связь многие-ко-многим ради агрегата рядом с объектом select_related() (Player.objects.annotate(Count("skills")).select_related("team")), с Cannot fold actions for projection (LOGICAL_ERROR) — ошибка его перестановки соединений; 25.10 и 26.x выполняют его.

Порядок текста

ClickHouse упорядочивает и сравнивает текст по его байтам UTF-8, как SQLite по умолчанию; PostgreSQL — по правилу сравнения (collation) своей базы, порядок локали в котором ставит a рядом с A, а буквы с диакритикой — рядом с их основой. order_by(), __gt/__lt и Min/Max у CharField, TextField и CharEnumField (сравнивается по метке) следуют порядку базы; Collate(...) просит у базы свой порядок — в ClickHouse только в order_by() (order_by(Collate("name", "en")) пишется как ORDER BY name ASC COLLATE 'en'): больше нигде сервер правило сравнения не принимает.

JSON

JSONField — колонка JSON, пути которой хранятся типизированными подколонками (кроме payload и headers outbox — см. Outbox); через clickhouse-driver, который не читает колонку JSON, это колонка String с текстом JSON, которую читают функции JSON*. Какая из них у подключения, следует из его драйвера ещё до открытия — hare drift, hare sqlmigrate и миграции сравнивают, печатают и создают колонку этого подключения. Таблицу, созданную через один драйвер, через другой тоже читают, пишут и фильтруют, кроме ключа с точкой: его пишут и по нему фильтруют через драйвер, создавший таблицу — через другой clickhouse-connect пишет его в колонку String с экранированной точкой (%2E), а clickhouse-driver не находит такой ключ колонки JSON; читается он верно через оба; hare drift через другой сообщает AlterField колонки — они по-разному хранят null, см. ниже. Тип JSON хранит меньше, чем текст JSON, и значение, которое он прочитал бы иначе, даёт ValidationError до отправки — колонка String через clickhouse-driver хранит всё это:

  • Хранится только объект: список, строка или число на верхнем уровне отклоняются.
  • Ключ со значением None или пустым объектом на любой глубине отклоняется ({"a": None}, {"items": [{"b": {}}]}) — такого пути тип не хранит. None или пустой объект внутри массива ([None, 1], [{}]) хранится. Поэтому field__path=None, None в field__path__in=[...] и field__path__isnull=True находят путь без значения — у строки, всё значение которой NULL, пути нет, — field__path__not=None и None в field__path__not_in=[...] его исключают, а сравнение пути (field__path__lt=10) его не находит.
  • Целое вне диапазона Int64 и UInt64 (от -2**63 до 2**64 - 1) отклоняется — тип хранит его как float.
  • clickhouse-connect задаёт тип пути строк одного bulk_create() по первому встреченному значению: целое после дробного по тому же пути пачки хранится и читается как float, и фильтр по его точному значению ничего не находит. Держите числа пути в пачке одного типа или пишите такие строки по одной; clickhouse-driver хранит текст JSON как есть.
  • Порядок ключей не сохраняется.
  • Точка в ключе остаётся частью ключа: каждое соединение задаёт json_type_escape_dots_in_keys, а hare пишет точку так, как её экранирует сервер, — %2E, в том числе в мутации update(), которая читает JSON с собственными настройками сервера. Ключ, в котором уже есть текст %2E, даёт ValidationError: сервер прочитал бы его обратно как точку.
  • Массив целых чисел и float хранит их все как float: [1, 2.5] читается как [1.0, 2.5]; массив, целые которого (в том числе во вложенных массивах) больше 2**53 — его float точно не хранит, — отклоняется, как и массив, где логическое значение стоит рядом с числом или строкой: мутация прочитала бы [true, 1] как [1, 1], а ["", true] — как ["", "true"]. Логическое значение остаётся логическим, а строка — строкой, даже похожая на дату или момент ("2024-01-02"): каждое подключение выключает input_format_try_infer_dates и input_format_try_infer_datetimes. Но update(), save() и bulk_update() пишут мутацией, которую ClickHouse выполняет с собственными настройками сервера, что бы ни ставило подключение: строка, похожая на дату, хранится путём Date или DateTime и читается как date или datetime, если профиль сервера по умолчанию не ставит эти две настройки в 0.

Пути ключей, __has_key, __has_keys и __has_any_keys работают на обоих типах; __filter, __contains и __contained_by не работают — в JSON ClickHouse нет вхождения, и они дают UnSupportedError; фильтруйте по путям ключей. __has_key, __has_keys и __has_any_keys находят только ключ объекта (JSONHas()). Миграция переводит колонку String в JSON через MODIFY COLUMN.

JSONField, сравниваемый целиком (=, __not, __in, __not_in) в колонке JSON, приводится к JSON и сравнивается самим ClickHouse, поэтому порядок ключей не важен; __in сравнивает так каждое значение, через OR. Колонка JSON хранит только объект: сравниваемое значение, которое не объект (список, строка, число), даёт ValidationError. В колонке String с текстом JSON — через clickhouse-driver — сравнивается текст JSON в том виде, в каком он записан.

Сравнение порядка пути (__gt, __gte, __lt, __lte, __range, order_by()) использует значение Dynamic, которое ClickHouse читает из текста JSON (JSONExtract(..., 'Dynamic'), настройка сессии allow_suspicious_types_in_order_by=1). ClickHouse упорядочивает Dynamic сначала по имени его типа, потом по значению: Float64 2.5 идёт перед Int64 9, логическое — перед числами, а строки — после. Сравнение пути, в котором лежат и строки, и числа, даёт NO_COMMON_TYPE ClickHouse. Порядок jsonb (объекты, массивы, логические, числа, строки, null) есть только в PostgreSQL, а сравнение с объектом или массивом — собственное сравнение ClickHouse.

Cast(..., TextField()) и Concat от JSONField дают текст, который пишет ClickHouse: порядок ключей колонки (собственный у типа JSON, записанный — в колонке String) и её запись числа (1e+20, 12.0). Текстовый оператор фильтра по пути сопоставляет текст, который ClickHouse читает из значения. JSONObject() и JSONArray() пишет toJSONString(): десятичное без хвостовых нулей, DateTime64 в виде 2024-02-29 13:45:06.789000, NaN как null, байты — шестнадцатеричной записью, которую хранит колонка.

BinaryField — колонка String с шестнадцатеричной записью байтов строчными буквами: clickhouse-connect читает String как текст и пишет байты, не являющиеся UTF-8, их шестнадцатеричной записью, поэтому сырые байты нельзя было бы прочитать обратно. Шестнадцатеричная запись читается обратно точно через оба драйвера и упорядочивается так же, как байты; unhex() даёт байты в SQL.

Массовая загрузка

bulk_create() отправляет строки одной двоичной вставкой — в формате ClickHouse Native, колонка за колонкой — вместо текста INSERT ... VALUES: литералы для значений не пишутся, а сервер не разбирает SQL. Так строки и должны попадать в ClickHouse, и поэтому много строк стоит писать через bulk_create(), а не через create() в цикле.

  • batch_size делит объекты на столько вставок; без него они идут одной.
  • Значения — те же, что пишет create(): момент — это мгновение, которое он обозначает (момент без часового пояса при use_timezone=False — мгновение его местного времени, и читается он обратно как это местное время), значение JSON — его текст или объект.
  • Наблюдатели, спаны и счётчик запросов видят вставку как INSERT INTO <table> (<columns>) FORMAT Native — строки не входят ни в какой текст SQL.
  • use_copy=True просит ту же загрузку и здесь ничего не меняет. returning=True и параметры конфликтов дают UnSupportedError.
  • С async_insert=true сервер собирает строки многих вставок, прежде чем записать их; с wait_for_async_insert=false вызов возвращается до записи и не сообщает об ошибке.