From 92ea8b30b1d4f52444ac61fef5f25d2a3b417257 Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Mon, 18 May 2026 16:47:48 +0300 Subject: [PATCH 1/2] Support timezone-qualified RowBinary datetimes --- lib/ch/row_binary.ex | 36 ++++++++++++++++++++++--------- pages/datetime-timezones.md | 9 ++++---- test/ch/row_binary_test.exs | 42 ++++++++++++++++++++++++------------- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/lib/ch/row_binary.ex b/lib/ch/row_binary.ex index 0f4a719..425ad54 100644 --- a/lib/ch/row_binary.ex +++ b/lib/ch/row_binary.ex @@ -106,11 +106,7 @@ defmodule Ch.RowBinary do ], do: t - defp encoding_type({:datetime = d, "UTC"}), do: d - - defp encoding_type({:datetime, tz}) do - raise ArgumentError, "can't encode DateTime with non-UTC timezone: #{inspect(tz)}" - end + defp encoding_type({:datetime = d, tz}) when is_binary(tz), do: {d, tz} defp encoding_type({:fixed_string, _len} = t), do: t @@ -155,11 +151,7 @@ defmodule Ch.RowBinary do defp encoding_type({:datetime64 = t, p}), do: {t, time_unit(p)} - defp encoding_type({:datetime64 = t, p, "UTC"}), do: {t, time_unit(p)} - - defp encoding_type({:datetime64, _, tz}) do - raise ArgumentError, "can't encode DateTime64 with non-UTC timezone: #{inspect(tz)}" - end + defp encoding_type({:datetime64 = t, p, tz}) when is_binary(tz), do: {t, time_unit(p), tz} defp encoding_type({:time64 = t, p}), do: {t, time_unit(p)} @@ -345,6 +337,16 @@ defmodule Ch.RowBinary do def encode(:datetime, nil), do: <<0::32>> + def encode({:datetime, timezone}, %NaiveDateTime{} = datetime) when is_binary(timezone) do + encode(:datetime, DateTime.from_naive!(datetime, timezone)) + end + + def encode({:datetime, timezone}, %DateTime{} = datetime) when is_binary(timezone) do + encode(:datetime, datetime) + end + + def encode({:datetime, timezone}, nil) when is_binary(timezone), do: encode(:datetime, nil) + def encode({:datetime64, time_unit}, %NaiveDateTime{} = datetime) do {seconds, micros} = NaiveDateTime.to_gregorian_seconds(datetime) @@ -357,6 +359,20 @@ defmodule Ch.RowBinary do def encode({:datetime64, _time_unit}, nil), do: <<0::64>> + def encode({:datetime64, time_unit, timezone}, %NaiveDateTime{} = datetime) + when is_binary(timezone) do + encode({:datetime64, time_unit}, DateTime.from_naive!(datetime, timezone)) + end + + def encode({:datetime64, time_unit, timezone}, %DateTime{} = datetime) + when is_binary(timezone) do + encode({:datetime64, time_unit}, datetime) + end + + def encode({:datetime64, time_unit, timezone}, nil) when is_binary(timezone) do + encode({:datetime64, time_unit}, nil) + end + def encode(:date, %Date{} = date) do <> end diff --git a/pages/datetime-timezones.md b/pages/datetime-timezones.md index 60d1039..25dcef2 100644 --- a/pages/datetime-timezones.md +++ b/pages/datetime-timezones.md @@ -91,11 +91,12 @@ RowBinary does not send text for `DateTime` values. It sends Unix timestamps dir | `NaiveDateTime` | `DateTime64(P)` | treated as a UTC naive value and encoded as Unix ticks | | `DateTime` | `DateTime` | encoded as Unix seconds for the instant | | `DateTime` | `DateTime64(P)` | encoded as Unix ticks for the instant | -| any | `DateTime('UTC')` | same as `DateTime` | -| any | `DateTime64(P, 'UTC')` | same as `DateTime64(P)` | -| any | non-UTC `DateTime(...)` / `DateTime64(...)` | not supported by `Ch.RowBinary.encode_rows/2` | +| `NaiveDateTime` | `DateTime('Europe/Berlin')` | treated as Berlin wall time and encoded as Unix seconds | +| `NaiveDateTime` | `DateTime64(P, 'Europe/Berlin')` | treated as Berlin wall time and encoded as Unix ticks | +| `DateTime` | `DateTime('Europe/Berlin')` | encoded as Unix seconds for the instant | +| `DateTime` | `DateTime64(P, 'Europe/Berlin')` | encoded as Unix ticks for the instant | -Use query parameters for non-UTC textual interpretation, or normalize values to UTC before RowBinary insertion. +For timezone-qualified `DateTime` and `DateTime64` types, `NaiveDateTime` values are interpreted in the timezone from the ClickHouse type. `DateTime` values already represent an instant, so their own timezone is normalized to Unix seconds or ticks before encoding. ## Practical Guidance diff --git a/test/ch/row_binary_test.exs b/test/ch/row_binary_test.exs index ad0bb11..696a642 100644 --- a/test/ch/row_binary_test.exs +++ b/test/ch/row_binary_test.exs @@ -111,33 +111,45 @@ defmodule Ch.RowBinaryTest do end test "encoding type aliases" do - assert encoding_types([{:datetime, "UTC"}]) == [:datetime] + assert encoding_types([{:datetime, "UTC"}]) == [{:datetime, "UTC"}] + assert encoding_types([{:datetime, "Europe/Vienna"}]) == [{:datetime, "Europe/Vienna"}] assert encoding_types([{:datetime64, 6}]) == [datetime64: 1_000_000] - assert encoding_types([{:datetime64, 3, "UTC"}]) == [datetime64: 1000] + assert encoding_types([{:datetime64, 3, "UTC"}]) == [{:datetime64, 1000, "UTC"}] + + assert encoding_types([{:datetime64, 3, "Europe/Vienna"}]) == + [{:datetime64, 1000, "Europe/Vienna"}] + assert encoding_types([{:decimal, 9, 4}]) == [decimal32: 4] assert encoding_types([{:decimal, 18, 4}]) == [decimal64: 4] assert encoding_types([{:decimal, 38, 4}]) == [decimal128: 4] assert encoding_types([{:decimal, 76, 4}]) == [decimal256: 4] assert encoding_types([{:simple_aggregate_function, "any", :u8}]) == [:u8] - # See https://github.com/plausible/ch/issues/353 - assert_raise ArgumentError, - "can't encode DateTime with non-UTC timezone: \"Europe/Vienna\"", - fn -> - encoding_types([{:datetime, "Europe/Vienna"}]) - end - - assert_raise ArgumentError, - "can't encode DateTime64 with non-UTC timezone: \"Europe/Vienna\"", - fn -> - encoding_types([{:datetime64, 3, "Europe/Vienna"}]) - end - assert_raise ArgumentError, "unsupported type for encoding: :unsupported", fn -> encoding_types([:unsupported]) end end + test "timezone-qualified datetime encodes naive values in the annotated timezone" do + types = ["DateTime('Europe/Vienna')", "DateTime64(3, 'Europe/Vienna')"] + + rows = [ + [~N[2022-01-01 12:00:00], ~N[2022-07-01 12:00:00.123456]], + [~U[2022-01-01 12:00:00Z], ~U[2022-07-01 12:00:00.123456Z]] + ] + + assert rows |> encode_rows(types) |> byte_by_byte(types) == [ + [ + DateTime.from_naive!(~N[2022-01-01 12:00:00], "Europe/Vienna"), + DateTime.from_naive!(~N[2022-07-01 12:00:00.123], "Europe/Vienna") + ], + [ + DateTime.shift_zone!(~U[2022-01-01 12:00:00Z], "Europe/Vienna"), + DateTime.shift_zone!(~U[2022-07-01 12:00:00.123Z], "Europe/Vienna") + ] + ] + end + test "decimal" do assert encode({:decimal, 9, 4}, Decimal.new("2.66")) == <<26600::32-little>> assert encode({:decimal, 18, 4}, Decimal.new("2.66")) == <<26600::64-little>> From 9e0808c2c766662a56394a7a41a714a59e9b5d94 Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Mon, 18 May 2026 16:50:59 +0300 Subject: [PATCH 2/2] Add changelog entry for RowBinary timezone fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 226f098..3c005bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Add explicit request and response compression support through HTTP headers. `zstd` and `gzip` response bodies are decompressed automatically for decoded `RowBinaryWithNamesAndTypes` and error responses; raw successful responses are kept as received in `Ch.Result.data`. - Fix `Time64` RowBinary encoding for precisions below microseconds. - Fix RowBinary integer encoders to reject out-of-range `Int16`/`UInt16` and wider values instead of silently wrapping, with added property coverage through 256-bit integer types. +- Fix RowBinary encoding for timezone-qualified `DateTime` and `DateTime64` values with non-UTC timezones. ## 0.8.3 (2026-05-12)