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
66 changes: 66 additions & 0 deletions bench/query.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
Benchee.run(
%{
"query string encode" => fn input ->
DBConnection.Query.Ch.Query.query_string_encode(input.query, input.params, input.opts)
end,
"multipart encode" => fn input ->
DBConnection.Query.Ch.Query.multipart_encode(input.query, input.params, input.opts)
end,
"custom multipart encode" => fn input ->
DBConnection.Query.Ch.Query.custom_multipart_encode(input.query, input.params, input.opts)
end
Copy link
Contributor Author

@ruslandoga ruslandoga Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Results
$ env MIX_ENV=bench mix run bench/query.exs
Operating System: macOS
CPU Information: Apple M2
Number of Available Cores: 8
Available memory: 8 GB
Elixir 1.18.4
Erlang 28.0.1
JIT enabled: true

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
reduction time: 0 ns
parallel: 1
inputs: 0 params, 1 named param, 10 named params, 10 positional params, 100 positional params
Estimated total run time: 1 min 45 s

Benchmarking custom multipart encode with input 0 params ...
Benchmarking custom multipart encode with input 1 named param ...
Benchmarking custom multipart encode with input 10 named params ...
Benchmarking custom multipart encode with input 10 positional params ...
Benchmarking custom multipart encode with input 100 positional params ...
Benchmarking multipart encode with input 0 params ...
Benchmarking multipart encode with input 1 named param ...
Benchmarking multipart encode with input 10 named params ...
Benchmarking multipart encode with input 10 positional params ...
Benchmarking multipart encode with input 100 positional params ...
Benchmarking query string encode with input 0 params ...
Benchmarking query string encode with input 1 named param ...
Benchmarking query string encode with input 10 named params ...
Benchmarking query string encode with input 10 positional params ...
Benchmarking query string encode with input 100 positional params ...
Calculating statistics...
Formatting results...

##### With input 0 params #####
Name                              ips        average  deviation         median         99th %
query string encode           11.23 M       89.07 ns ±42180.35%          42 ns          84 ns
custom multipart encode        1.59 M      627.63 ns  ±3688.39%         500 ns         916 ns
multipart encode               0.48 M     2076.18 ns   ±729.98%        1625 ns        3792 ns

Comparison:
query string encode           11.23 M
custom multipart encode        1.59 M - 7.05x slower +538.55 ns
multipart encode               0.48 M - 23.31x slower +1987.11 ns

##### With input 1 named param #####
Name                              ips        average  deviation         median         99th %
query string encode            6.02 M      166.10 ns ±18451.53%         125 ns         250 ns
custom multipart encode        1.40 M      715.88 ns  ±2991.42%         583 ns         792 ns
multipart encode               0.33 M     3041.13 ns   ±475.63%        2418 ns        5709 ns

Comparison:
query string encode            6.02 M
custom multipart encode        1.40 M - 4.31x slower +549.77 ns
multipart encode               0.33 M - 18.31x slower +2875.02 ns

##### With input 10 named params #####
Name                              ips        average  deviation         median         99th %
query string encode         1900.51 K        0.53 μs  ±4765.09%        0.46 μs        0.67 μs
custom multipart encode      454.49 K        2.20 μs   ±462.59%        1.75 μs        4.58 μs
multipart encode              82.65 K       12.10 μs    ±38.17%       11.33 μs       33.46 μs

Comparison:
query string encode         1900.51 K
custom multipart encode      454.49 K - 4.18x slower +1.67 μs
multipart encode              82.65 K - 22.99x slower +11.57 μs

##### With input 10 positional params #####
Name                              ips        average  deviation         median         99th %
query string encode         1364.13 K        0.73 μs  ±2293.63%        0.71 μs        0.96 μs
custom multipart encode      962.79 K        1.04 μs  ±1354.36%        0.88 μs        1.38 μs
multipart encode              86.87 K       11.51 μs    ±59.70%          10 μs       37.06 μs

Comparison:
query string encode         1364.13 K
custom multipart encode      962.79 K - 1.42x slower +0.31 μs
multipart encode              86.87 K - 15.70x slower +10.78 μs

##### With input 100 positional params #####
Name                              ips        average  deviation         median         99th %
custom multipart encode      207.53 K        4.82 μs   ±120.68%        4.33 μs        9.63 μs
query string encode          132.51 K        7.55 μs   ±404.52%        7.25 μs       10.42 μs
multipart encode               8.45 K      118.36 μs    ±19.60%      113.04 μs      203.48 μs

Comparison:
custom multipart encode      207.53 K
query string encode          132.51 K - 1.57x slower +2.73 μs
multipart encode               8.45 K - 24.56x slower +113.54 μs

},
inputs: %{
"0 params" => %{
query: %Ch.Query{
statement: "select 1",
command: :select,
encode: true,
decode: true
},
params: [],
opts: []
},
"1 named param" => %{
query: %Ch.Query{
statement: "select {a:UInt8}",
command: :select,
encode: true,
decode: true
},
params: %{"a" => 1},
opts: []
},
"10 named params" => %{
query: %Ch.Query{
statement: "select " <> Enum.map_join(1..10, ", ", &"{a#{&1}:UInt8}"),
command: :select,
encode: true,
decode: true
},
params: Map.new(1..10, &{"a#{&1}", &1}),
opts: []
},
"10 positional params" => %{
query: %Ch.Query{
statement: "select " <> Enum.map_join(1..10, ", ", &"{$#{&1}:UInt8}"),
command: :select,
encode: true,
decode: true
},
params: Enum.to_list(1..10),
opts: []
},
"100 positional params" => %{
query: %Ch.Query{
statement: "select " <> Enum.map_join(1..100, ", ", &"{$#{&1}:UInt8}"),
command: :select,
encode: true,
decode: true
},
params: Enum.to_list(1..100),
opts: []
}
}
# profile_after: true
)
94 changes: 93 additions & 1 deletion lib/ch/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,52 @@ defimpl DBConnection.Query, for: Ch.Query do
end
end

def encode(%Query{statement: statement}, params, opts) do
def encode(%Query{} = q, params, opts) do
custom_multipart_encode(q, params, opts)
end

def query_string_encode(%Query{statement: statement}, params, opts) do
types = Keyword.get(opts, :types)
default_format = if types, do: "RowBinary", else: "RowBinaryWithNamesAndTypes"
format = Keyword.get(opts, :format) || default_format
{query_params(params), [{"x-clickhouse-format", format} | headers(opts)], statement}
end

def multipart_encode(%Query{statement: statement}, params, opts) do
types = Keyword.get(opts, :types)
default_format = if types, do: "RowBinary", else: "RowBinaryWithNamesAndTypes"
format = Keyword.get(opts, :format) || default_format

form =
query_params(params)
|> Enum.reduce(Multipart.new(), fn {k, v}, acc ->
Multipart.add_part(acc, Multipart.Part.text_field(v, k))
end)
|> Multipart.add_part(Multipart.Part.text_field(IO.iodata_to_binary(statement), "query"))

content_type = Multipart.content_type(form, "multipart/form-data")

{_no_query_params = [],
[{"x-clickhouse-format", format}, {"content-type", content_type} | headers(opts)],
Multipart.body_binary(form)}
end

def custom_multipart_encode(%Query{statement: statement}, params, opts) do
types = Keyword.get(opts, :types)
default_format = if types, do: "RowBinary", else: "RowBinaryWithNamesAndTypes"
format = Keyword.get(opts, :format) || default_format

boundary = "ChFormBoundary" <> Base.url_encode64(:crypto.strong_rand_bytes(24))
content_type = "multipart/form-data; boundary=\"#{boundary}\""
enc_boundary = "--#{boundary}\r\n"
multipart = multipart_params(params, enc_boundary)
multipart = add_multipart_part(multipart, "query", statement, enc_boundary)
multipart = [multipart | "--#{boundary}--\r\n"]

{_no_query_params = [],
[{"x-clickhouse-format", format}, {"content-type", content_type} | headers(opts)], multipart}
end

defp format_row_binary?(statement) when is_binary(statement) do
statement |> String.trim_trailing() |> String.ends_with?("RowBinary")
end
Expand Down Expand Up @@ -208,6 +247,59 @@ defimpl DBConnection.Query, for: Ch.Query do
end
end

defp multipart_params(params, boundary) when is_map(params) do
multipart_named_params(Map.to_list(params), boundary, [])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make acc default to [] ? cleaner implementation imo

end

defp multipart_params(params, boundary) when is_list(params) do
multipart_positional_params(params, 0, boundary, [])
end

defp multipart_named_params([{name, value} | params], boundary, acc) do
acc =
add_multipart_part(
acc,
"param_" <> URI.encode_www_form(name),
encode_param(value),
boundary
)

multipart_named_params(params, boundary, acc)
end

defp multipart_named_params([], _boundary, acc), do: acc

defp multipart_positional_params([value | params], idx, boundary, acc) do
acc =
add_multipart_part(
acc,
"param_$" <> Integer.to_string(idx),
encode_param(value),
boundary
)

multipart_positional_params(params, idx + 1, boundary, acc)
end

defp multipart_positional_params([], _idx, _boundary, acc), do: acc

@compile inline: [add_multipart_part: 4]
defp add_multipart_part(multipart, name, value, boundary) do
part = [
boundary,
"content-disposition: form-data; name=\"",
name,
"\"\r\n\r\n",
value,
"\r\n"
]

case multipart do
[] -> part
_ -> [multipart | part]
end
end

defp query_params(params) when is_map(params) do
Enum.map(params, fn {k, v} -> {"param_#{k}", encode_param(v)} end)
end
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ defmodule Ch.MixProject do
{:benchee, "~> 1.0", only: [:bench]},
{:dialyxir, "~> 1.0", only: [:dev], runtime: false},
{:ex_doc, ">= 0.0.0", only: :docs},
{:tz, "~> 0.28.1", only: [:test]}
{:tz, "~> 0.28.1", only: [:test]},
{:multipart, "~> 0.4.0"}
]
end

Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"multipart": {:hex, :multipart, "0.4.0", "634880a2148d4555d050963373d0e3bbb44a55b2badd87fa8623166172e9cda0", [:mix], [{:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "3c5604bc2fb17b3137e5d2abdf5dacc2647e60c5cc6634b102cf1aef75a06f0a"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
Expand Down