Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
36 changes: 26 additions & 10 deletions lib/ch/row_binary.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)}

Expand Down Expand Up @@ -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)

Expand All @@ -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
<<Date.to_gregorian_days(date) - @epoch_gregorian_days::16-little>>
end
Expand Down
9 changes: 5 additions & 4 deletions pages/datetime-timezones.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 27 additions & 15 deletions test/ch/row_binary_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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>>
Expand Down