From 8ef19753f082f7a5d713318464976050c5c26dec Mon Sep 17 00:00:00 2001 From: Maksim Burtsev Date: Sat, 22 Aug 2026 00:31:08 +0400 Subject: [PATCH 1/2] fix(tests): adapt the EXPLAIN test to newer ClickHouse output ClickHouse 26.7 made `explain_query_plan_default = 'pretty'` the default, and the pretty plan carries a blank line between the output columns and the plan tree. A blank TSV line decodes to an empty Record (that is what the `WITH TOTALS` separator looks like), so indexing every row of an EXPLAIN result stopped working. EXPLAIN SYNTAX also renders operators as function calls now ("SELECT plus(1, 1)"), so the test no longer asserts the exact spelling. Co-Authored-By: Claude Opus 5 --- tests.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests.py b/tests.py index 0002227..3ad0277 100644 --- a/tests.py +++ b/tests.py @@ -1168,10 +1168,17 @@ async def test_explain_with_fetch(self): # https://github.com/maximdanilchenko/aiochclient/issues/98 rows = await self.ch.fetch("EXPLAIN SELECT 1") assert rows - assert all(isinstance(row[0], str) for row in rows) + # ClickHouse 26.7 made the `pretty` plan format the default, and it puts + # a blank line between the output columns and the plan tree. A blank TSV + # line decodes to an empty Record (that is what the `WITH TOTALS` + # separator looks like), so only the non-empty rows carry plan text. + assert any(len(row) for row in rows) + assert all(isinstance(row[0], str) for row in rows if len(row)) value = await self.ch.fetchval("EXPLAIN SYNTAX SELECT 1 + 1") - assert value == "SELECT 1 + 1" + # ClickHouse 26.7 prints operators as function calls; older servers + # printed the operator form. + assert value in ("SELECT plus(1, 1)", "SELECT 1 + 1") async def test_quoted_string(self): record = await self.ch.fetchrow("SELECT 'foo\\'bar' AS quoted_string") From 3d54df0915c32ed3169ec725b6b08369400dc1d4 Mon Sep 17 00:00:00 2001 From: Maksim Burtsev Date: Sat, 22 Aug 2026 19:03:26 +0400 Subject: [PATCH 2/2] feat: return tz-aware datetimes for DateTime/DateTime64 with a timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DateTime('TZ') / DateTime64(P, 'TZ') column now decodes to a datetime carrying that zone (zoneinfo) on every engine; a column without a zone stays naive. Writers accept naive values (wall-clock in the column zone, as before) and aware values of any zone (stored as the instant they denote) — previously an aware value was silently re-zoned or raised TypeError. One server quirk remains: the Native header ships DateTime('TZ') as plain DateTime (DateTime64 keeps its zone), so that column is naive UTC on the native engine only. Closes the DateTime64-with-timezone item of #136. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 9 ++- README.md | 14 ++++- aiochclient/_types.pyx | 53 ++++++++-------- aiochclient/native.py | 19 +++--- aiochclient/types.py | 61 +++++++++--------- tests.py | 138 +++++++++++++++++++++++++++++++++++------ 6 files changed, 203 insertions(+), 91 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92a8fb9..e88635f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,9 +114,12 @@ supported Python is 3.10 (`python_requires` in `setup.py`). - Query `params` use `str.format`, so literal `{`/`}` in SQL (e.g. cluster macros) must be escaped as `{{`/`}}`. - The TSV streaming backend asserts the response ends on a line boundary (`assert not buffer`). -- **Native engine specifics**: `decode=False` (raw bytes) is TSV-only; the Native format - carries no per-column timezone, so a tz-aware `DateTime`/`DateTime64` comes back as naive - UTC. A multi-block native INSERT is **not atomic** on a client-side encoding error (blocks +- **Timezones**: a `DateTime('TZ')` / `DateTime64(P, 'TZ')` column decodes to a tz-aware + `datetime` (`zoneinfo`); a column without a zone stays naive. Writers accept naive (column + wall-clock) and aware (any zone) values. One server quirk: the Native header ships a + `DateTime('TZ')` column as plain `DateTime` (DateTime64 keeps its zone), so that column is + naive UTC on the native engine only. +- **Native engine specifics**: `decode=False` (raw bytes) is TSV-only. A multi-block native INSERT is **not atomic** on a client-side encoding error (blocks already streamed are committed) — `insert_block_size=0` forces a single atomic block. The GC is disabled inside the synchronous decode/build sections of a fetch (mass allocation otherwise trips the cyclic collector); it is always restored via `try/finally`, and those diff --git a/README.md b/README.md index 490150c..a9d04ac 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,9 @@ Because binary encoding is type-specific, a binary INSERT first looks up the target column types (one lightweight query) and encodes the rows to match them. A couple of `native=True` caveats: `decode=False` (raw bytes) is a TSV-only -feature, and the Native format does not carry a column's timezone, so a tz-aware -`DateTime`/`DateTime64` comes back as a naive UTC `datetime` (TSV and RowBinary -apply the timezone). +feature, and ClickHouse's Native header drops the timezone of a `DateTime('TZ')` +column (`DateTime64` keeps it), so that one column comes back as a naive UTC +`datetime` on the native engine (TSV and RowBinary return it tz-aware). A native INSERT streams its body as several Native blocks so the server can insert one while the client encodes the next (`insert_block_size` rows per block, @@ -259,7 +259,9 @@ vice-versa. | `Enum16` | `str` | | `Date` | `datetime.date` | | `DateTime` | `datetime.datetime` | +| `DateTime('TZ')` | tz-aware `datetime` | | `DateTime64` | `datetime.datetime` | +| `DateTime64(P, 'TZ')`| tz-aware `datetime` | | `Decimal` | `decimal.Decimal` | | `Decimal32` | `decimal.Decimal` | | `Decimal64` | `decimal.Decimal` | @@ -273,6 +275,12 @@ vice-versa. | `Nullable(T)` | `None` or `T` | | `LowCardinality(T)` | `T` | | `Map(T1, T2)` | `Dict[T1, T2]` | + +A column declared with a timezone decodes to a `datetime` carrying that zone +(`zoneinfo.ZoneInfo`); a column without one stays naive. On INSERT a naive +`datetime` is taken as wall-clock time in the column's zone (UTC for a column +without one), and a tz-aware `datetime` of any zone is stored as the instant it +denotes. | `Nested(T1, T2, ...)` | `List[Tuple[T1, T2, ...], Tuple[T1, T2, ...]]` | diff --git a/aiochclient/_types.pyx b/aiochclient/_types.pyx index 9e840c3..f296a81 100644 --- a/aiochclient/_types.pyx +++ b/aiochclient/_types.pyx @@ -11,18 +11,12 @@ from uuid import UUID from zoneinfo import ZoneInfo from cpython cimport PyList_Append, PyUnicode_AsEncodedString, PyUnicode_Join +from cpython.datetime cimport date, date_new, datetime, datetime_new, import_datetime from cpython.list cimport PyList_GET_ITEM, PyList_GET_SIZE +from cpython.mem cimport PyMem_Free, PyMem_Malloc from cpython.ref cimport Py_INCREF from cpython.tuple cimport PyTuple_New, PyTuple_SET_ITEM -from cpython.datetime cimport ( - date, - date_new, - datetime, - datetime_new, - import_datetime, -) from cpython.unicode cimport PyUnicode_DecodeUTF8 -from cpython.mem cimport PyMem_Free, PyMem_Malloc from libc.stdint cimport ( int8_t, int16_t, @@ -216,6 +210,21 @@ cdef class Cursor: RB_EPOCH_DATE = _dt.date(1970, 1, 1) RB_EPOCH_DATETIME = _dt.datetime(1970, 1, 1) RB_EPOCH_UTC = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) +_MICROSECOND = _dt.timedelta(microseconds=1) + + +cpdef object to_epoch_micros(object value, object zone): + """Microseconds since the epoch for a naive or aware ``value``. + + Naive + column zone -> wall-clock in that zone. Naive + no zone -> UTC + wall-clock (the historical behaviour). Aware -> its real instant, whatever + the column zone is. + """ + if value.tzinfo is None: + if zone is None: + return (value - RB_EPOCH_DATETIME) // _MICROSECOND + value = value.replace(tzinfo=zone) + return (value - RB_EPOCH_UTC) // _MICROSECOND _TZ_UNSET = object() @@ -884,12 +893,14 @@ cdef class DateTimeType(_RBType): cdef object _convert(self, str string): string = string.strip("'") try: - return datetime_parse(string) + value = datetime_parse(string) except ValueError: # In case of 0000-00-00 00:00:00 if string == "0000-00-00 00:00:00": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value cpdef object p_type(self, str string): return self._convert(string) @@ -902,14 +913,10 @@ cdef class DateTimeType(_RBType): zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + _dt.timedelta(seconds=seconds) - return _dt.datetime.fromtimestamp(seconds, zone).replace(tzinfo=None) + return _dt.datetime.fromtimestamp(seconds, zone) cpdef bytes write(self, value): - zone = self._zone() - if zone is None: - seconds = (value - RB_EPOCH_DATETIME) // _dt.timedelta(seconds=1) - else: - seconds = int(value.replace(tzinfo=zone).timestamp()) + seconds = to_epoch_micros(value, self._zone()) // 1_000_000 return int(seconds).to_bytes(4, "little") @@ -937,12 +944,14 @@ cdef class DateTime64Type(_RBType): cdef object _convert(self, str string): string = string.strip("'") try: - return datetime_parse_f(string) + value = datetime_parse_f(string) except ValueError: # In case of 0000-00-00 00:00:00.000 if string == "0000-00-00 00:00:00.000": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value cpdef object p_type(self, str string): return self._convert(string) @@ -959,18 +968,10 @@ cdef class DateTime64Type(_RBType): zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + _dt.timedelta(microseconds=micros) - return (RB_EPOCH_UTC + _dt.timedelta(microseconds=micros)).astimezone( - zone - ).replace(tzinfo=None) + return (RB_EPOCH_UTC + _dt.timedelta(microseconds=micros)).astimezone(zone) cpdef bytes write(self, value): - zone = self._zone() - if zone is None: - micros = (value - RB_EPOCH_DATETIME) // _dt.timedelta(microseconds=1) - else: - micros = (value.replace(tzinfo=zone) - RB_EPOCH_UTC) // _dt.timedelta( - microseconds=1 - ) + micros = to_epoch_micros(value, self._zone()) if self._precision <= 6: ticks = micros // 10 ** (6 - self._precision) else: diff --git a/aiochclient/native.py b/aiochclient/native.py index 5bbf25f..7e0c467 100644 --- a/aiochclient/native.py +++ b/aiochclient/native.py @@ -29,9 +29,9 @@ # Use the compiled Cursor (with bulk column reads) when available. try: - from aiochclient._types import Cursor # noqa: F401 + from aiochclient._types import Cursor, to_epoch_micros # noqa: F401 except ImportError: - from aiochclient.types import Cursor # noqa: F401 + from aiochclient.types import Cursor, to_epoch_micros # noqa: F401 # Compiled String-column encoder; pure-Python fallback below. try: @@ -213,10 +213,10 @@ def decode_column(cursor, n, ctype): return [data[i * width : (i + 1) * width].decode() for i in range(n)] if ctype == "Date": return cursor.read_date_column(n) - if ctype == "DateTime" or ctype.startswith("DateTime("): - # The Native format does not carry the per-column timezone (it always - # ships the UTC epoch), so DateTime is returned as a naive UTC datetime. - # The TSV/RowBinary engines apply the column timezone instead. + if ctype == "DateTime": + # ClickHouse's Native header drops the timezone of a DateTime('TZ') + # column (it ships plain "DateTime"; DateTime64 keeps its zone), so a + # timezone DateTime can only come back as naive UTC on this engine. return cursor.read_datetime_column(n) if ctype.startswith("Nullable("): inner = ctype[9:-1] @@ -341,7 +341,6 @@ async def rows_from_native( # Epochs for the null-slot defaults and the bulk Date/DateTime encoders. _EPOCH_DATE = dt.date(1970, 1, 1) _EPOCH_DATETIME = dt.datetime(1970, 1, 1) -_ONE_SECOND = dt.timedelta(seconds=1) def _write_varint(value): @@ -461,10 +460,10 @@ def encode_column(values, ctype): column.byteswap() return column.tobytes() if ctype == "DateTime": - # Naive UTC seconds (matches the no-timezone DateTime writer). Timezone - # DateTime / DateTime64 keep the per-value writer path below. + # Naive UTC / aware seconds (matches the no-timezone DateTime writer). + # Timezone DateTime / DateTime64 keep the per-value writer path below. column = array.array( - "I", [(v - _EPOCH_DATETIME) // _ONE_SECOND for v in values] + "I", [to_epoch_micros(v, None) // 1_000_000 for v in values] ) if not _LE: column.byteswap() diff --git a/aiochclient/types.py b/aiochclient/types.py index 1164138..2065fcd 100644 --- a/aiochclient/types.py +++ b/aiochclient/types.py @@ -144,6 +144,22 @@ def _parse_tz(name: str) -> Optional[str]: # Epoch used to turn day/second/tick offsets into python date/datetime objects. RB_EPOCH_DATE = dt.date(1970, 1, 1) RB_EPOCH_DATETIME = dt.datetime(1970, 1, 1) +RB_EPOCH_UTC = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) +_MICROSECOND = dt.timedelta(microseconds=1) + + +def to_epoch_micros(value: dt.datetime, zone) -> int: + """Microseconds since the epoch for a naive or aware ``value``. + + Naive + column zone -> wall-clock in that zone. Naive + no zone -> UTC + wall-clock (the historical behaviour). Aware -> its real instant, whatever + the column zone is. + """ + if value.tzinfo is None: + if zone is None: + return (value - RB_EPOCH_DATETIME) // _MICROSECOND + value = value.replace(tzinfo=zone) + return (value - RB_EPOCH_UTC) // _MICROSECOND def write_varint(value: int) -> bytes: @@ -455,12 +471,14 @@ def _zone(self): def p_type(self, string: str): string = string.strip("'") try: - return datetime_parse(string) + value = datetime_parse(string) except ValueError: # In case of 0000-00-00 00:00:00 if string == "0000-00-00 00:00:00": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value def convert(self, value: bytes) -> Optional[dt.datetime]: return self.p_type(value.decode()) @@ -470,24 +488,18 @@ def read(self, cursor) -> dt.datetime: zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + dt.timedelta(seconds=seconds) - # Match the server's TSV output: wall-clock in the column timezone. - return dt.datetime.fromtimestamp(seconds, zone).replace(tzinfo=None) + return dt.datetime.fromtimestamp(seconds, zone) def write(self, value: dt.datetime) -> bytes: - zone = self._zone() - if zone is None: - seconds = (value - RB_EPOCH_DATETIME) // dt.timedelta(seconds=1) - else: - # ``value`` is naive wall-clock in the column timezone. - seconds = int(value.replace(tzinfo=zone).timestamp()) + seconds = to_epoch_micros(value, self._zone()) // 1_000_000 return seconds.to_bytes(4, "little") @staticmethod def unconvert(value: dt.datetime) -> bytes: - if value.microsecond != 0: - # In case of 0000-00-00 00:00:00.000 (datetime64) - return b"%a" % dt.datetime.strftime(value, '%Y-%m-%d %H:%M:%S.%f') - return b"%a" % dt.datetime.strftime(value, '%Y-%m-%d %H:%M:%S') + # str() keeps the sub-second part only when present (a DateTime column + # rejects it) and the UTC offset of an aware value, which ClickHouse + # applies. Same as the compiled ``unconvert_datetime``. + return b"'%s'" % str(value).encode() class DateTime64Type(BaseType): @@ -506,12 +518,14 @@ def _zone(self): def p_type(self, string: str): string = string.strip("'") try: - return datetime_parse_f(string) + value = datetime_parse_f(string) except ValueError: # In case of 0000-00-00 00:00:00 if string == "0000-00-00 00:00:00.000": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value def convert(self, value: bytes) -> Optional[dt.datetime]: return self.p_type(value.decode()) @@ -526,25 +540,10 @@ def read(self, cursor) -> dt.datetime: zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + dt.timedelta(microseconds=micros) - # Match the server's TSV output: wall-clock in the column timezone. - return ( - ( - dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) - + dt.timedelta(microseconds=micros) - ) - .astimezone(zone) - .replace(tzinfo=None) - ) + return (RB_EPOCH_UTC + dt.timedelta(microseconds=micros)).astimezone(zone) def write(self, value: dt.datetime) -> bytes: - zone = self._zone() - if zone is None: - micros = (value - RB_EPOCH_DATETIME) // dt.timedelta(microseconds=1) - else: - aware = value.replace(tzinfo=zone) - micros = ( - aware - dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) - ) // dt.timedelta(microseconds=1) + micros = to_epoch_micros(value, self._zone()) if self._precision <= 6: ticks = micros // 10 ** (6 - self._precision) else: diff --git a/tests.py b/tests.py index 3ad0277..a612a51 100644 --- a/tests.py +++ b/tests.py @@ -6,6 +6,7 @@ from enum import Enum, IntEnum from ipaddress import IPv4Address, IPv6Address from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo import aiohttp import httpx @@ -21,6 +22,9 @@ def uuid(): return uuid4() +MOSCOW = ZoneInfo("Europe/Moscow") + + @pytest.fixture def rows(uuid): return [ @@ -73,7 +77,7 @@ def rows(uuid): [[1, 2, 3], [1, 2], [6, 7]], IPv4Address('116.253.40.133'), IPv6Address('2001:44c8:129:2632:33:0:252:2'), - dt.datetime(2018, 9, 21, 10, 32, 23, 999000), + dt.datetime(2018, 9, 21, 10, 32, 23, 999000, tzinfo=MOSCOW), True, {"hello": "world {' and other things"}, {"hello": {"inner": "world {' and other things"}}, @@ -174,7 +178,8 @@ async def all_types_db(chclient, rows): await chclient.execute("DROP TABLE IF EXISTS test_cache") await chclient.execute("DROP TABLE IF EXISTS test_cache_mv") await chclient.execute("DROP TABLE IF EXISTS test_insert_file") - await chclient.execute(""" + await chclient.execute( + """ CREATE TABLE all_types (uint8 UInt8, uint16 UInt16, uint32 UInt32, @@ -228,27 +233,34 @@ async def all_types_db(chclient, rows): nested_int Nested(value1 Integer, value2 Integer), nested_str_date Nested(value1 String, value2 Date) ) ENGINE = Memory - """) - await chclient.execute(""" + """ + ) + await chclient.execute( + """ CREATE TABLE test_cache ( key String, int32Cache AggregateFunction(avg, Int32), float32Cache SimpleAggregateFunction(sum, Float64)) ENGINE = AggregatingMergeTree() ORDER BY key - """) - await chclient.execute(""" + """ + ) + await chclient.execute( + """ CREATE MATERIALIZED VIEW test_cache_mv TO test_cache AS SELECT avgState(int32) AS int32Cache, sum(float32) AS float32Cache FROM all_types - """) - await chclient.execute(""" + """ + ) + await chclient.execute( + """ CREATE TABLE test_insert_file( uint32 UInt32, string String, date Date ) ENGINE = Memory - """) + """ + ) await chclient.execute("INSERT INTO all_types VALUES", *rows) @@ -257,7 +269,7 @@ def class_chclient(chclient, all_types_db, rows, request): request.cls.ch = chclient cls_rows = rows cls_rows[1][45] = dt.datetime( - 2019, 1, 1, 3, 0 + 2019, 1, 1, 3, 0, tzinfo=MOSCOW ) # DateTime64 always returns datetime type request.cls.rows = [tuple(r) for r in cls_rows] @@ -299,9 +311,7 @@ async def test_bad_select(self): @pytest.mark.types @pytest.mark.usefixtures("class_chclient", "class_engine") class TestTypes: - # The Native format ships DateTime64 without its timezone, so a tz-aware - # column comes back as naive UTC and cannot match the tz-aware TSV value. - NATIVE_UNSUPPORTED = {"datetime64"} + NATIVE_UNSUPPORTED = set() # The binary engines decode Float32 from its exact 4-byte IEEE-754 value, # whereas TSV ships ClickHouse's shorter text rounding (e.g. 23.432 vs @@ -928,7 +938,7 @@ async def test_ipv6(self): assert await self.select_field_bytes("ipv6") == b"2001:44c8:129:2632:33:0:252:2" async def test_datetime64(self): - result = dt.datetime(2018, 9, 21, 10, 32, 23, 999000) + result = dt.datetime(2018, 9, 21, 10, 32, 23, 999000, tzinfo=MOSCOW) assert await self.select_field("datetime64") == result record = await self.select_record("datetime64") assert record[0] == result @@ -1586,7 +1596,8 @@ async def test_single_column_empty_string(self): async def test_insert_round_trip(self): binary = self._binary_client() await binary.execute("DROP TABLE IF EXISTS rb_insert") - await binary.execute(""" + await binary.execute( + """ CREATE TABLE rb_insert ( u8 UInt8, i64 Int64, f Float64, s String, fs FixedString(4), d Date, dttm DateTime('UTC'), @@ -1595,7 +1606,8 @@ async def test_insert_round_trip(self): e Enum8('a' = 1, 'b' = 2), arr Array(UInt8), m Map(String, UInt8), nn Nullable(UInt8), tup Tuple(UInt8, String) ) ENGINE = Memory - """) + """ + ) row = ( 7, -5, @@ -1603,8 +1615,8 @@ async def test_insert_round_trip(self): "hi", "abcd", dt.date(2021, 5, 6), - dt.datetime(2021, 5, 6, 7, 8, 9), - dt.datetime(2021, 5, 6, 10, 8, 9, 123000), + dt.datetime(2021, 5, 6, 7, 8, 9, tzinfo=ZoneInfo("UTC")), + dt.datetime(2021, 5, 6, 10, 8, 9, 123000, tzinfo=MOSCOW), Decimal("12.3456"), UUID("1ea47c97-16a8-4338-877e-66f464374944"), IPv4Address("1.2.3.4"), @@ -1637,6 +1649,96 @@ async def test_insert_with_column_list(self): await binary.execute("DROP TABLE IF EXISTS rb_insert_cols") +class TestDateTimeTimezone: + # https://github.com/maximdanilchenko/aiochclient/issues/136 + # A DateTime('TZ') / DateTime64(P, 'TZ') column decodes to a tz-aware + # datetime in the column zone; a timezone-less column stays naive. On + # INSERT every engine accepts naive (column wall-clock) and aware (any + # zone) values alike. + DDL = """ + CREATE TABLE dt_tz ( + d DateTime('Europe/Moscow'), d64 DateTime64(3, 'Europe/Moscow'), + n DateTime, n64 DateTime64(3), nd Nullable(DateTime64(3, 'UTC')), + arr Array(DateTime64(6, 'UTC')) + ) ENGINE = Memory + """ + + @pytest.fixture(params=["tsv", "binary", "native"]) + async def writer(self, chclient, request): + ch = ChClient( + chclient._http_client._session, + **({} if request.param == "tsv" else {request.param: True}), + ) + await ch.execute("DROP TABLE IF EXISTS dt_tz") + await ch.execute(self.DDL) + yield ch + await ch.execute("DROP TABLE IF EXISTS dt_tz") + + @pytest.fixture + def readers(self, chclient): + session = chclient._http_client._session + return { + "tsv": chclient, + "binary": ChClient(session, binary=True), + "native": ChClient(session, native=True), + } + + async def test_round_trip(self, writer, readers): + instant = dt.datetime(2021, 3, 28, 1, 30, 45, 123000, tzinfo=dt.timezone.utc) + naive = dt.datetime(2021, 3, 28, 1, 30, 45, 123000) + # DateTime (no 64) keeps whole seconds only. + instant_s, naive_s = instant.replace(microsecond=0), naive.replace( + microsecond=0 + ) + await writer.execute( + "INSERT INTO dt_tz VALUES", + (instant, instant, instant, instant, None, [instant, naive]), + (naive, naive, naive, naive, naive, []), + ) + # The server sees the very instants that were sent. + stamps = await writer.fetch( + "SELECT toUnixTimestamp(d), toUnixTimestamp64Milli(d64)," + " toUnixTimestamp(n), toUnixTimestamp64Milli(n64) FROM dt_tz ORDER BY d" + ) + # Naive 01:30 Moscow is 22:30 UTC the day before, so it sorts first. + assert [r[:] for r in stamps] == [ + ( + int(naive.replace(tzinfo=MOSCOW).timestamp()), + int(naive.replace(tzinfo=MOSCOW).timestamp() * 1000), + int(naive.replace(tzinfo=dt.timezone.utc).timestamp()), + int(naive.replace(tzinfo=dt.timezone.utc).timestamp() * 1000), + ), + ( + int(instant.timestamp()), + int(instant.timestamp() * 1000), + int(instant.timestamp()), + int(instant.timestamp() * 1000), + ), + ] + for engine, ch in readers.items(): + rows = await ch.fetch("SELECT * FROM dt_tz ORDER BY d") + wall, aware = rows[0], rows[1] + # aware == aware compares instants, whatever the zone. + assert aware["d64"] == instant, engine + assert aware["d64"].tzinfo.key == "Europe/Moscow", engine + assert aware["n"] == naive_s, engine + assert aware["n"].tzinfo is None, engine + assert aware["n64"] == naive and aware["n64"].tzinfo is None, engine + assert aware["nd"] is None, engine + assert aware["arr"] == [instant, naive.replace(tzinfo=dt.timezone.utc)] + assert aware["arr"][0].tzinfo.key == "UTC", engine + assert wall["d64"] == naive.replace(tzinfo=MOSCOW), engine + assert wall["nd"] == naive.replace(tzinfo=dt.timezone.utc), engine + if engine == "native": + # ClickHouse's Native header drops the zone of a DateTime('TZ') + # column, so it can only be decoded as naive UTC there. + assert aware["d"] == instant_s.replace(tzinfo=None), engine + else: + assert aware["d"] == instant_s, engine + assert aware["d"].tzinfo.key == "Europe/Moscow", engine + assert wall["d"] == naive_s.replace(tzinfo=MOSCOW), engine + + @pytest.mark.usefixtures("class_chclient") class TestNative: # https://github.com/maximdanilchenko/aiochclient/issues/134