Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- add experimental [Time](https://clickhouse.com/docs/sql-reference/data-types/time) and [Time64](https://clickhouse.com/docs/sql-reference/data-types/time64) types support https://github.com/plausible/ch/pull/260

## 0.4.1 (2025-07-07)

- fix column decoding when count exceeds 127 https://github.com/plausible/ch/pull/257
Expand Down
1 change: 1 addition & 0 deletions lib/ch/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ defimpl DBConnection.Query, for: Ch.Query do
defp encode_param(%Decimal{} = d), do: Decimal.to_string(d, :normal)
defp encode_param(%Date{} = date), do: Date.to_iso8601(date)
defp encode_param(%NaiveDateTime{} = naive), do: NaiveDateTime.to_iso8601(naive)
defp encode_param(%Time{} = time), do: Time.to_iso8601(time)

defp encode_param(%DateTime{microsecond: microsecond} = dt) do
dt = DateTime.shift_zone!(dt, "Etc/UTC")
Expand Down
70 changes: 68 additions & 2 deletions lib/ch/row_binary.ex
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ defmodule Ch.RowBinary do
:date,
:datetime,
:date32,
:time,
:ipv4,
:ipv6,
:point,
Expand Down Expand Up @@ -156,6 +157,8 @@ defmodule Ch.RowBinary do
raise ArgumentError, "can't encode DateTime64 with non-UTC timezone: #{inspect(tz)}"
end

defp encoding_type({:time64 = t, p}), do: {t, time_unit(p)}

defp encoding_type({e, mappings}) when e in [:enum8, :enum16] do
{e, Map.new(mappings)}
end
Expand Down Expand Up @@ -321,11 +324,11 @@ defmodule Ch.RowBinary do
<<DateTime.diff(datetime, @epoch_utc_datetime, time_unit)::64-little-signed>>
end

def encode({:datetime64, _precision}, %DateTime{} = datetime) do
def encode({:datetime64, _time_unit}, %DateTime{} = datetime) do
raise ArgumentError, "non-UTC timezones are not supported for encoding: #{datetime}"
end

def encode({:datetime64, _precision}, nil), do: <<0::64>>
def encode({:datetime64, _time_unit}, nil), do: <<0::64>>

def encode(:date, %Date{} = date) do
<<Date.diff(date, @epoch_date)::16-little>>
Expand All @@ -339,6 +342,29 @@ defmodule Ch.RowBinary do

def encode(:date32, nil), do: <<0::32>>

def encode(:time, %Time{} = time) do
{s, _micros} = Time.to_seconds_after_midnight(time)
<<s::32-little-signed>>
end

def encode(:time, nil), do: <<0::32>>

def encode({:time64, time_unit}, %Time{} = time) do
{s, micros} = Time.to_seconds_after_midnight(time)

micros_as_ticks =
cond do
time_unit < 1_000_000 -> div(micros, time_unit)
time_unit == 1_000_000 -> micros
true -> micros * div(time_unit, 1_000_000)
end

ticks = s * time_unit + micros_as_ticks
<<ticks::64-little-signed>>
end

def encode({:time64, _time_unit}, nil), do: <<0::64>>

def encode(:uuid, <<u1::64, u2::64>>), do: <<u1::64-little, u2::64-little>>

def encode(
Expand Down Expand Up @@ -551,6 +577,8 @@ defmodule Ch.RowBinary do
:uuid,
:date,
:date32,
:time,
:time64,
:ipv4,
:ipv6,
:point,
Expand Down Expand Up @@ -594,6 +622,8 @@ defmodule Ch.RowBinary do
defp decoding_type({:datetime64 = t, p}), do: {t, time_unit(p), _tz = nil}
defp decoding_type({:datetime64 = t, p, tz}), do: {t, time_unit(p), tz}

defp decoding_type({:time64 = t, p}), do: {t, time_unit(p)}

defp decoding_type({e, mappings}) when e in [:enum8, :enum16] do
{e, Map.new(mappings, fn {k, v} -> {v, k} end)}
end
Expand Down Expand Up @@ -878,6 +908,42 @@ defmodule Ch.RowBinary do
<<d::32-little-signed, bin::bytes>> = bin
decode_rows(types_rest, bin, [Date.add(@epoch_date, d) | row], rows, types)

:time ->
<<s::32-little-signed, bin::bytes>> = bin

t =
if s >= 0 and s < 86400 do
Time.from_seconds_after_midnight(s)
else
# since ClickHouse supports Time values of [-999:59:59, 999:59:59]
# and Elixir's Time supports values of [00:00:00, 23:59:59]
# we raise an error when ClickHouse's Time value is out of Elixir's Time range
raise ArgumentError,
"ClickHouse Time value #{s} (seconds) is out of Elixir's Time range (00:00:00 - 23:59:59)"

# TODO: we could potentially decode ClickHouse's Time values as Elixir's Duration when it's out of Elixir's Time range
end

decode_rows(types_rest, bin, [t | row], rows, types)

{:time64, time_unit} ->
<<ticks::64-little-signed, bin::bytes>> = bin

t =
if ticks >= 0 and ticks < 86400 * time_unit do
ticks |> DateTime.from_unix!(time_unit) |> DateTime.to_time()
else
# since ClickHouse supports Time64 values of [-999:59:59.999999999, 999:59:59.999999999]
# and Elixir's Time supports values of [00:00:00.000000, 23:59:59.999999]
# we raise an error when ClickHouse's Time64 value is out of Elixir's Time range
raise ArgumentError,
"ClickHouse Time value #{ticks / time_unit} (seconds) is out of Elixir's Time range (00:00:00.000000 - 23:59:59.999999)"

# TODO: we could potentially decode ClickHouse's Time64 values as Elixir's Duration when it's out of Elixir's Time range
end

decode_rows(types_rest, bin, [t | row], rows, types)

{:datetime, timezone} ->
<<s::32-little, bin::bytes>> = bin

Expand Down
3 changes: 3 additions & 0 deletions lib/ch/types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ defmodule Ch.Types do
{"DateTime64", :datetime64, [:int, :string]},
{"DateTime", :datetime, [:string]},
# {"DateTime", :datetime, []},
{"Time64", :time64, [:int]},
{"Time", :time, []},
{"Date32", :date32, []},
{"Date", :date, []},
{"LowCardinality", :low_cardinality, [:type]},
Expand Down Expand Up @@ -443,6 +445,7 @@ defmodule Ch.Types do
defp build_type(:decimal128 = d, [s]), do: {d, s}
defp build_type(:decimal256 = d, [s]), do: {d, s}
defp build_type(:decimal = d, [s, p]), do: {d, p, s}
defp build_type(:time64 = t, [precision]), do: {t, precision}

defp build_enum_mapping(mapping) do
mapping |> :lists.reverse() |> Enum.chunk_every(2) |> Enum.map(fn [k, v] -> {k, v} end)
Expand Down
176 changes: 176 additions & 0 deletions test/ch/connection_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,182 @@ defmodule Ch.ConnectionTest do
Ch.query!(conn, "SELECT event_id FROM new WHERE timestamp = '1960-01-01'")
end

# https://clickhouse.com/docs/sql-reference/data-types/time
@tag :time
test "time", %{conn: conn} do
settings = [enable_time_time64_type: 1]

Ch.query!(conn, "CREATE TABLE time_t(`time` Time, `event_id` UInt8) ENGINE = Memory", [],
settings: settings
)

Ch.query!(conn, "INSERT INTO time_t VALUES ('100:00:00', 1), (12453, 2)", [],
settings: settings
)

# ClickHouse supports Time values of [-999:59:59, 999:59:59]
# and Elixir's Time supports values of [00:00:00, 23:59:59]
# so we raise an error when ClickHouse's Time value is out of Elixir's Time range

assert_raise ArgumentError,
"ClickHouse Time value 360000 (seconds) is out of Elixir's Time range (00:00:00 - 23:59:59)",
fn -> Ch.query!(conn, "select * from time_t", [], settings: settings) end

Ch.query!(
conn,
"INSERT INTO time_t(time, event_id) FORMAT RowBinary",
_rows = [
[~T[00:00:00], 3],
[~T[12:34:56], 4],
[~T[23:59:59], 5]
],
settings: settings,
types: ["Time", "UInt8"]
)

assert Ch.query!(conn, "select * from time_t where event_id > 1 order by event_id", [],
settings: settings
).rows ==
[[~T[03:27:33], 2], [~T[00:00:00], 3], [~T[12:34:56], 4], [~T[23:59:59], 5]]
end

# https://clickhouse.com/docs/sql-reference/data-types/time64
@tag :time
test "Time64(3)", %{conn: conn} do
settings = [enable_time_time64_type: 1]

Ch.query!(
conn,
"CREATE TABLE time64_3_t(`time` Time64(3), `event_id` UInt8) ENGINE = Memory",
[],
settings: settings
)

Ch.query!(
conn,
"INSERT INTO time64_3_t VALUES (15463123, 1), (154600.123, 2), ('100:00:00', 3);",
[],
settings: settings
)

# ClickHouse supports Time64 values of [-999:59:59.999999999, 999:59:59.999999999]
# and Elixir's Time supports values of [00:00:00.000000, 23:59:59.999999]
# so we raise an error when ClickHouse's Time64 value is out of Elixir's Time range

assert_raise ArgumentError,
"ClickHouse Time value 154600.123 (seconds) is out of Elixir's Time range (00:00:00.000000 - 23:59:59.999999)",
fn -> Ch.query!(conn, "select * from time64_3_t", [], settings: settings) end

Ch.query!(
conn,
"INSERT INTO time64_3_t(time, event_id) FORMAT RowBinary",
_rows = [
[~T[00:00:00.000000], 4],
[~T[12:34:56.012300], 5],
[~T[12:34:56.123456], 6],
[~T[12:34:56.120000], 7],
[~T[23:59:59.999999], 8]
],
settings: settings,
types: ["Time64(3)", "UInt8"]
)

assert Ch.query!(
conn,
"select * from time64_3_t where time < {max_elixir_time:Time64(6)} order by event_id",
%{"max_elixir_time" => ~T[23:59:59.999999]},
settings: settings
).rows ==
[
[~T[04:17:43.123], 1],
[~T[00:00:00.000], 4],
[~T[12:34:56.012], 5],
[~T[12:34:56.123], 6],
[~T[12:34:56.120], 7],
[~T[23:59:59.999], 8]
]
end

@tag :time
test "Time64(6)", %{conn: conn} do
settings = [enable_time_time64_type: 1]

Ch.query!(
conn,
"CREATE TABLE time64_6_t(`time` Time64(6), `event_id` UInt8) ENGINE = Memory",
[],
settings: settings
)

Ch.query!(
conn,
"INSERT INTO time64_6_t(time, event_id) FORMAT RowBinary",
_rows = [
[~T[00:00:00.000000], 1],
[~T[12:34:56.123456], 2],
[~T[12:34:56.123000], 3],
[~T[12:34:56.000123], 4],
[~T[23:59:59.999999], 5]
],
settings: settings,
types: ["Time64(6)", "UInt8"]
)

assert Ch.query!(
conn,
"select * from time64_6_t order by event_id",
[],
settings: settings
).rows ==
[
[~T[00:00:00.000000], 1],
[~T[12:34:56.123456], 2],
[~T[12:34:56.123000], 3],
[~T[12:34:56.000123], 4],
[~T[23:59:59.999999], 5]
]
end

@tag :time
test "Time64(9)", %{conn: conn} do
settings = [enable_time_time64_type: 1]

Ch.query!(
conn,
"CREATE TABLE time64_9_t(`time` Time64(9), `event_id` UInt8) ENGINE = Memory",
[],
settings: settings
)

Ch.query!(
conn,
"INSERT INTO time64_9_t(time, event_id) FORMAT RowBinary",
_rows = [
[~T[00:00:00.000000], 1],
[~T[12:34:56.123456], 2],
[~T[12:34:56.123000], 3],
[~T[12:34:56.000123], 4],
[~T[23:59:59.999999], 5]
],
settings: settings,
types: ["Time64(9)", "UInt8"]
)

assert Ch.query!(
conn,
"select * from time64_9_t order by event_id",
[],
settings: settings
).rows ==
[
[~T[00:00:00.000000], 1],
[~T[12:34:56.123456], 2],
[~T[12:34:56.123000], 3],
[~T[12:34:56.000123], 4],
[~T[23:59:59.999999], 5]
]
end

test "datetime64", %{conn: conn} do
Ch.query!(
conn,
Expand Down
Loading