Packages
ch
0.8.2
0.8.3
0.8.2
0.8.1
0.8.0
0.7.3
0.7.2
0.7.1
0.7.0
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
retired
0.5.3
retired
0.5.2
retired
0.5.1
retired
0.5.0
retired
0.4.3
0.4.2
0.4.1
0.4.0
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.5-rc.0
0.2.4
0.2.3
0.2.2
0.2.2-rc.3
0.2.2-rc.2
0.2.2-rc.1
0.2.2-rc.0
0.2.1
0.2.0
0.1.15
0.1.14
0.1.13
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
retired
0.1.6
retired
0.1.5
retired
0.1.4
retired
0.1.3
retired
0.1.2
retired
0.1.1
0.1.0
HTTP ClickHouse driver for Elixir
Current section
Files
Jump to
Current section
Files
lib/ch/connection.ex
defmodule Ch.Connection do
@moduledoc false
use DBConnection
require Logger
alias Ch.{Error, Query, Result, RowBinary}
alias Mint.HTTP1, as: HTTP
@user_agent "ch/" <> Mix.Project.config()[:version]
@typep conn :: HTTP.t()
@impl true
@spec connect([Ch.start_option()]) :: {:ok, conn} | {:error, Error.t() | Mint.Types.error()}
def connect(opts) do
scheme = String.to_existing_atom(opts[:scheme] || "http")
address = opts[:hostname] || "localhost"
port = opts[:port] || 8123
mint_opts = [mode: :passive] ++ Keyword.take(opts, [:hostname, :transport_opts])
with {:ok, conn} <- HTTP.connect(scheme, address, port, mint_opts) do
conn =
conn
|> HTTP.put_private(:timeout, opts[:timeout] || :timer.seconds(15))
|> maybe_put_private(:database, opts[:database])
|> maybe_put_private(:username, opts[:username])
|> maybe_put_private(:password, opts[:password])
|> maybe_put_private(:settings, opts[:settings])
handshake = Query.build("select 1, version()")
params = DBConnection.Query.encode(handshake, _params = [], _opts = [])
case handle_execute(handshake, params, _opts = [], conn) do
{:ok, handshake, responses, conn} ->
case DBConnection.Query.decode(handshake, responses, _opts = []) do
%Result{rows: [[1, version]]} ->
conn =
if parse_version(version) >= parse_version("24.10") do
settings =
HTTP.get_private(conn, :settings, [])
|> Keyword.put_new(:input_format_binary_read_json_as_string, 1)
|> Keyword.put_new(:output_format_binary_write_json_as_string, 1)
HTTP.put_private(conn, :settings, settings)
else
conn
end
{:ok, conn}
result ->
{:ok, _conn} = HTTP.close(conn)
reason = Error.exception("unexpected result for '#{handshake}': #{inspect(result)}")
{:error, reason}
end
{:error, reason, conn} ->
{:ok, _conn} = HTTP.close(conn)
{:error, reason}
{disconnect, reason, conn} when disconnect in [:disconnect, :disconnect_and_retry] ->
{:ok, _conn} = HTTP.close(conn)
{:error, reason}
end
end
catch
_kind, reason -> {:error, reason}
end
defp parse_version(version) do
version
|> String.split(".")
|> Enum.flat_map(fn segment ->
case Integer.parse(segment) do
{int, _rest} -> [int]
:error -> []
end
end)
end
@impl true
@spec ping(conn) :: {:ok, conn} | {:disconnect, Mint.Types.error() | Error.t(), conn}
def ping(conn) do
headers = [{"user-agent", @user_agent}]
case request(conn, "GET", "/ping", headers, _body = "", _opts = []) do
{:ok, conn, _response} -> {:ok, conn}
{:error, error, conn} -> {:disconnect, error, conn}
{:disconnect, _error, _conn} = disconnect -> disconnect
end
end
@impl true
@spec checkout(conn) :: {:ok, conn}
def checkout(conn), do: {:ok, conn}
# we "support" these four tx callbacks for Repo.checkout
# even though ClickHouse doesn't support txs
@impl true
def handle_begin(_opts, conn), do: {:ok, %{}, conn}
@impl true
def handle_commit(_opts, conn), do: {:ok, %{}, conn}
@impl true
def handle_rollback(_opts, conn), do: {:ok, %{}, conn}
@impl true
def handle_status(_opts, conn), do: {:idle, conn}
@impl true
def handle_prepare(_query, _opts, conn) do
{:error, Error.exception("prepared statements are not supported"), conn}
end
@impl true
def handle_close(_query, _opts, conn) do
{:error, Error.exception("prepared statements are not supported"), conn}
end
@impl true
def handle_declare(query, params, opts, conn) do
%Query{command: command, decode: decode} = query
{query_params, extra_headers, body} = params
path = path(conn, query_params, opts)
headers = headers(conn, extra_headers, opts)
timeout = timeout(conn, opts)
with {:ok, conn, _ref} <- send_request(conn, "POST", path, headers, body),
{:ok, conn, columns, headers, reader} <- recv_declare(conn, decode, timeout) do
result = %Result{
command: command,
columns: columns,
rows: [],
num_rows: 0,
headers: headers,
data: []
}
{:ok, query, result, {conn, reader}}
else
{:error, _reason, _conn} = client_error -> client_error
{:disconnect, reason, conn} -> {:disconnect_and_retry, reason, conn}
end
end
defp recv_declare(conn, decode, timeout) do
acc = %{decode: decode, step: :status, buffer: [], headers: []}
recv_declare_continue(conn, acc, timeout)
end
defp recv_declare_continue(conn, acc, timeout) do
case HTTP.recv(conn, 0, timeout) do
{:ok, conn, responses} ->
case handle_recv_declare(responses, acc) do
{:ok, columns, headers, reader} ->
{:ok, conn, columns, headers, reader}
{:more, acc} ->
recv_declare_continue(conn, acc, timeout)
:error ->
all_responses_result =
case handle_all_responses(responses, []) do
{:ok, responses} -> {:ok, conn, responses}
{:more, acc} -> recv_all(conn, acc, timeout)
end
with {:ok, conn, responses} <- all_responses_result do
[_status, headers | data] = responses
message = IO.iodata_to_binary(data)
code =
if code = get_header(headers, "x-clickhouse-exception-code") do
String.to_integer(code)
end
{:error, Error.exception(code: code, message: message), conn}
end
end
{:error, conn, error, _responses} ->
{:disconnect, error, conn}
end
end
defp handle_recv_declare([{:status, _ref, status} | responses], %{step: :status} = acc) do
case status do
200 -> handle_recv_declare(responses, %{acc | step: :headers})
_other -> :error
end
end
defp handle_recv_declare([{:headers, _ref, headers} | responses], %{step: :headers} = acc) do
with %{decode: true} <- acc,
"RowBinaryWithNamesAndTypes" <- get_header(headers, "x-clickhouse-format") do
handle_recv_declare(responses, %{acc | headers: headers, step: :columns})
else
_ ->
reader = %{decode: false, responses: responses}
{:ok, _columns = nil, headers, reader}
end
end
defp handle_recv_declare([{:data, _ref, data} | responses], %{step: :columns} = acc) do
buffer = maybe_concat_buffer(acc.buffer, data)
case RowBinary.decode_header(buffer) do
{:ok, names, types, buffer} ->
reader = %{buffer: buffer, types: types, state: nil, responses: responses}
{:ok, names, acc.headers, reader}
:more ->
handle_recv_declare(responses, %{acc | buffer: buffer})
end
end
defp handle_recv_declare([], acc), do: {:more, acc}
@compile inline: [maybe_concat_buffer: 2]
defp maybe_concat_buffer("", data), do: data
defp maybe_concat_buffer(buffer, data) when is_binary(buffer), do: buffer <> data
defp maybe_concat_buffer([], data), do: data
@impl true
def handle_fetch(query, %Result{} = result, opts, {conn, reader}) do
case reader do
%{responses: []} ->
handle_fetch_recv(query, result, opts, conn, reader)
%{decode: false, responses: responses} ->
case responses do
[{:data, _ref, data} | responses] ->
result = %Result{result | data: data}
reader = %{reader | responses: responses}
{:cont, result, {conn, reader}}
[{:done, _ref}] ->
reader = %{reader | responses: []}
{:halt, result, {conn, reader}}
end
%{buffer: buffer, types: types, state: state, responses: responses} ->
case responses do
[{:data, _ref, data} | responses] ->
buffer = maybe_concat_buffer(buffer, data)
{rows, buffer, state} = RowBinary.decode_rows_continue(buffer, types, state)
result = %Result{result | data: data, rows: rows, num_rows: length(rows)}
reader = %{reader | buffer: buffer, state: state, responses: responses}
{:cont, result, {conn, reader}}
[{:done, _ref}] ->
reader = %{reader | responses: []}
{:halt, result, {conn, reader}}
end
end
end
defp handle_fetch_recv(query, result, opts, conn, reader) do
timeout = timeout(conn, opts)
case HTTP.recv(conn, 0, timeout) do
{:ok, conn, responses} ->
reader = %{reader | responses: responses}
handle_fetch(query, result, opts, {conn, reader})
{:error, conn, reason, _responses} ->
{:disconnect, reason, conn}
end
end
@impl true
def handle_deallocate(_query, %Result{} = result, _opts, {conn, _reader}) do
case HTTP.open_request_count(conn) do
0 ->
{:ok, %{result | data: []}, conn}
1 ->
error =
Error.exception("stopping stream before receiving full response by closing connection")
{:disconnect, error, conn}
end
end
@impl true
def handle_execute(%Query{} = query, {:stream, params}, opts, conn) do
{query_params, extra_headers, body} = params
path = path(conn, query_params, opts)
headers = headers(conn, extra_headers, opts)
with {:ok, conn, ref} <- send_request(conn, "POST", path, headers, :stream) do
case HTTP.stream_request_body(conn, ref, body) do
{:ok, conn} -> {:ok, query, ref, conn}
{:error, conn, reason} -> {:disconnect_and_retry, reason, conn}
end
end
end
def handle_execute(%Query{} = query, {:stream, ref, body}, opts, conn) do
case HTTP.stream_request_body(conn, ref, body) do
{:ok, conn} ->
case body do
:eof ->
with {:ok, conn, responses} <- receive_full_response(conn, timeout(conn, opts)) do
{:ok, query, responses, conn}
end
_other ->
{:ok, query, ref, conn}
end
{:error, conn, reason} ->
{:disconnect_and_retry, reason, conn}
end
end
def handle_execute(%Query{command: :insert} = query, params, opts, conn) do
{query_params, extra_headers, body} = params
path = path(conn, query_params, opts)
headers = headers(conn, extra_headers, opts)
result =
if is_function(body, 2) do
request_chunked(conn, "POST", path, headers, body, opts)
else
request(conn, "POST", path, headers, body, opts)
end
case result do
{:ok, conn, responses} -> {:ok, query, responses, conn}
{:error, _reason, _conn} = client_error -> client_error
{:disconnect, reason, conn} -> {:disconnect_and_retry, reason, conn}
end
end
def handle_execute(query, params, opts, conn) do
{query_params, extra_headers, body} = params
path = path(conn, query_params, opts)
headers = headers(conn, extra_headers, opts)
case request(conn, "POST", path, headers, body, opts) do
{:ok, conn, responses} -> {:ok, query, responses, conn}
{:error, _reason, _conn} = client_error -> client_error
{:disconnect, reason, conn} -> {:disconnect_and_retry, reason, conn}
end
end
@impl true
def disconnect(error, {conn, _reader}) do
disconnect(error, conn)
end
def disconnect(_error, conn) do
{:ok = ok, _conn} = HTTP.close(conn)
ok
end
@typep response :: Mint.Types.status() | Mint.Types.headers() | binary
@spec request(conn, binary, binary, Mint.Types.headers(), iodata, [Ch.query_option()]) ::
{:ok, conn, [response]}
| {:error, Error.t(), conn}
| {:disconnect, Mint.Types.error(), conn}
defp request(conn, method, path, headers, body, opts) do
with {:ok, conn, _ref} <- send_request(conn, method, path, headers, body) do
receive_full_response(conn, timeout(conn, opts))
end
end
@spec request_chunked(conn, binary, binary, Mint.Types.headers(), Enumerable.t(), Keyword.t()) ::
{:ok, conn, [response]}
| {:error, Error.t(), conn}
| {:disconnect, Mint.Types.error(), conn}
def request_chunked(conn, method, path, headers, stream, opts) do
with {:ok, conn, ref} <- send_request(conn, method, path, headers, :stream),
{:ok, conn} <- stream_body(conn, ref, stream),
do: receive_full_response(conn, timeout(conn, opts))
end
@spec stream_body(conn, Mint.Types.request_ref(), Enumerable.t()) ::
{:ok, conn} | {:disconnect, Mint.Types.error(), conn}
defp stream_body(conn, ref, stream) do
result =
stream
|> Stream.concat([:eof])
|> Enum.reduce_while({:ok, conn}, fn
chunk, {:ok, conn} -> {:cont, HTTP.stream_request_body(conn, ref, chunk)}
_chunk, {:error, _conn, _reason} = error -> {:halt, error}
end)
case result do
{:ok, _conn} = ok -> ok
{:error, conn, reason} -> {:disconnect, reason, conn}
end
end
# stacktrace is a bit cleaner with this function inlined
@compile inline: [send_request: 5]
defp send_request(conn, method, path, headers, body) do
case HTTP.request(conn, method, path, headers, body) do
{:ok, _conn, _ref} = ok -> ok
{:error, conn, reason} -> {:disconnect, reason, conn}
end
end
@spec receive_full_response(conn, timeout) ::
{:ok, conn, [response]}
| {:error, Error.t(), conn}
| {:disconnect, Mint.Types.error(), conn}
defp receive_full_response(conn, timeout) do
with {:ok, conn, responses} <- recv_all(conn, [], timeout) do
case responses do
[200, headers | _rest] ->
conn = ensure_same_server(conn, headers)
{:ok, conn, responses}
[_status, headers | data] ->
message = IO.iodata_to_binary(data)
code =
if code = get_header(headers, "x-clickhouse-exception-code") do
String.to_integer(code)
end
{:error, Error.exception(code: code, message: message), conn}
end
end
end
@spec recv_all(conn, [response], timeout()) ::
{:ok, conn, [response]} | {:disconnect, Mint.Types.error(), conn}
defp recv_all(conn, acc, timeout) do
case HTTP.recv(conn, 0, timeout) do
{:ok, conn, responses} ->
case handle_all_responses(responses, acc) do
{:ok, responses} -> {:ok, conn, responses}
{:more, acc} -> recv_all(conn, acc, timeout)
end
{:error, conn, reason, _responses} ->
{:disconnect, reason, conn}
end
end
for tag <- [:data, :status, :headers] do
defp handle_all_responses([{unquote(tag), _ref, data} | rest], acc) do
handle_all_responses(rest, [data | acc])
end
end
defp handle_all_responses([{:done, _ref}], acc), do: {:ok, :lists.reverse(acc)}
defp handle_all_responses([], acc), do: {:more, acc}
defp maybe_put_private(conn, _k, nil), do: conn
defp maybe_put_private(conn, k, v), do: HTTP.put_private(conn, k, v)
defp timeout(conn), do: HTTP.get_private(conn, :timeout)
defp timeout(conn, opts), do: Keyword.get(opts, :timeout) || timeout(conn)
defp settings(conn, opts) do
default_settings = HTTP.get_private(conn, :settings, [])
opts_settings = Keyword.get(opts, :settings, [])
Keyword.merge(default_settings, opts_settings)
end
defp headers(conn, extra_headers, opts) do
extra_headers
|> maybe_put_new_header("x-clickhouse-user", get_opts_or_private(conn, opts, :username))
|> maybe_put_new_header("x-clickhouse-key", get_opts_or_private(conn, opts, :password))
|> maybe_put_new_header("x-clickhouse-database", get_opts_or_private(conn, opts, :database))
|> maybe_put_new_header("user-agent", @user_agent)
end
defp get_opts_or_private(conn, opts, key) do
Keyword.get(opts, key) || HTTP.get_private(conn, key)
end
defp maybe_put_new_header(headers, _name, _no_value = nil), do: headers
defp maybe_put_new_header(headers, name, value) do
if List.keymember?(headers, name, 0) do
headers
else
[{name, value} | headers]
end
end
defp get_header(headers, key) do
case List.keyfind(headers, key, 0) do
{_, value} -> value
nil = not_found -> not_found
end
end
defp path(conn, query_params, opts) do
settings = settings(conn, opts)
"/?" <> URI.encode_query(settings ++ query_params)
end
@server_display_name_key :server_display_name
@spec ensure_same_server(conn, Mint.Types.headers()) :: conn
defp ensure_same_server(conn, headers) do
expected_name = HTTP.get_private(conn, @server_display_name_key)
actual_name = get_header(headers, "x-clickhouse-server-display-name")
cond do
expected_name && actual_name ->
unless actual_name == expected_name do
Logger.warning(
"Server mismatch detected. Expected #{inspect(expected_name)} but got #{inspect(actual_name)}!" <>
" Connection pooling might be unstable."
)
end
conn
actual_name ->
HTTP.put_private(conn, @server_display_name_key, actual_name)
true ->
conn
end
end
end