Skip to content

Commit 8b57f02

Browse files
ruslandogahawkyredependabot[bot]
authored
support multipart requests (#290)
* multipart requests * to false * update docs and interface * remove to string * custom multipart and slight refactor * merge * docs * doc title * more cleanup * send settings as params * Bump actions/checkout from 4 to 5 (#270) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](actions/checkout@v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix version check (#274) * changelog * add older ClickHouse to CI * tag 'json as string' test as json * release v0.5.5 * update deps * comment on why older version in ci * shorter cache key * fix internal type ordering in Variant (#275) * fix internal type ordering in Variant * cleanup * link pr * release v0.5.6 * fewer changes * eh * eh x2 * readme * dialyzer * a few more tests * more tests * typos skip * eh * eh! * cleanup * continue * continue --------- Signed-off-by: dependabot[bot] <[email protected]> Co-authored-by: hawkyre <[email protected]> Co-authored-by: Pablo Molina <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent 3d46f10 commit 8b57f02

19 files changed

+1393
-735
lines changed

.typos.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
[default.extend-words]
22
"som" = "som" # ./test/ch/ecto_type_test.exs
33
"ECT" = "ECT" # ./test/ch/query_test.exs
4+
"Evn" = "Evn" # ./CHANGELOG.md

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,50 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- added support for `multipart/form-data` in queries: https://github.com/plausible/ch/pull/290 -- which allows bypassing URL length limits sometimes imposed by reverse proxies when sending queries with many parameters.
6+
7+
⚠️ This is currently **opt-in** per query ⚠️
8+
9+
Global support for the entire connection pool is planned for a future release.
10+
11+
**Usage**
12+
13+
Pass `multipart: true` in the options list for `Ch.query/4`
14+
15+
```elixir
16+
# Example usage
17+
Ch.query(pool, "SELECT {a:String}, {b:String}", %{"a" => "A", "b" => "B"}, multipart: true)
18+
```
19+
20+
<details>
21+
<summary>View raw request format reference</summary>
22+
23+
```http
24+
POST / HTTP/1.1
25+
content-length: 387
26+
host: localhost:8123
27+
user-agent: ch/0.6.2-dev
28+
x-clickhouse-format: RowBinaryWithNamesAndTypes
29+
content-type: multipart/form-data; boundary="ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw"
30+
31+
--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw
32+
content-disposition: form-data; name="param_a"
33+
34+
A
35+
--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw
36+
content-disposition: form-data; name="param_b"
37+
38+
B
39+
--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw
40+
content-disposition: form-data; name="query"
41+
42+
select {a:String}, {b:String}
43+
--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw--
44+
```
45+
46+
</details>
47+
348
## 0.6.1 (2025-12-04)
449

550
- handle disconnect during stream https://github.com/plausible/ch/pull/283

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,24 @@ Note on datetime encoding in query parameters:
6767
- `%NaiveDateTime{}` is encoded as text to make it assume the column's or ClickHouse server's timezone
6868
- `%DateTime{}` is encoded as unix timestamp and is treated as UTC timestamp by ClickHouse
6969

70+
#### Select rows (lots of params, reverse proxy)
71+
72+
For queries with many parameters the resulting URL can become too long for some reverse proxies, resulting in a `414 Request-URI Too Large` error.
73+
74+
To avoid this, you can use the `multipart: true` option to send the query and parameters in the request body.
75+
76+
```elixir
77+
{:ok, pid} = Ch.start_link()
78+
79+
# Moves parameters from the URL to a multipart/form-data body
80+
%Ch.Result{rows: [[[1, 2, 3 | _rest]]]} =
81+
Ch.query!(pid, "SELECT {ids:Array(UInt64)}", %{"ids" => Enum.to_list(1..10_000)}, multipart: true)
82+
```
83+
84+
> [!NOTE]
85+
>
86+
> `multipart: true` is currently required on each individual query. Support for pool-wide configuration is planned for a future release.
87+
7088
#### Insert rows
7189

7290
```elixir

lib/ch.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ defmodule Ch do
5959
# TODO remove
6060
| {:encode, boolean}
6161
| {:decode, boolean}
62+
| {:multipart, boolean}
6263
| DBConnection.connection_option()
6364

6465
@doc """
@@ -76,6 +77,7 @@ defmodule Ch do
7677
* `:headers` - Custom HTTP headers for the request
7778
* `:format` - Custom response format for the request
7879
* `:decode` - Whether to automatically decode the response
80+
* `:multipart` - Whether to send the query as multipart/form-data
7981
* [`DBConnection.connection_option()`](https://hexdocs.pm/db_connection/DBConnection.html#t:connection_option/0)
8082
8183
"""

lib/ch/query.ex

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
defmodule Ch.Query do
22
@moduledoc "Query struct wrapping the SQL statement."
3-
defstruct [:statement, :command, :encode, :decode]
3+
defstruct [:statement, :command, :encode, :decode, :multipart]
44

5-
@type t :: %__MODULE__{statement: iodata, command: command, encode: boolean, decode: boolean}
5+
@type t :: %__MODULE__{
6+
statement: iodata,
7+
command: command,
8+
encode: boolean,
9+
decode: boolean,
10+
multipart: boolean
11+
}
612

713
@doc false
814
@spec build(iodata, [Ch.query_option()]) :: t
915
def build(statement, opts \\ []) do
1016
command = Keyword.get(opts, :command) || extract_command(statement)
1117
encode = Keyword.get(opts, :encode, true)
1218
decode = Keyword.get(opts, :decode, true)
13-
%__MODULE__{statement: statement, command: command, encode: encode, decode: decode}
19+
multipart = Keyword.get(opts, :multipart, false)
20+
21+
%__MODULE__{
22+
statement: statement,
23+
command: command,
24+
encode: encode,
25+
decode: decode,
26+
multipart: multipart
27+
}
1428
end
1529

1630
statements = [
@@ -72,6 +86,7 @@ defmodule Ch.Query do
7286
end
7387

7488
defimpl DBConnection.Query, for: Ch.Query do
89+
@dialyzer :no_improper_lists
7590
alias Ch.{Query, Result, RowBinary}
7691

7792
@spec parse(Query.t(), [Ch.query_option()]) :: Query.t()
@@ -128,13 +143,82 @@ defimpl DBConnection.Query, for: Ch.Query do
128143
end
129144
end
130145

146+
def encode(%Query{multipart: true, statement: statement}, params, opts) do
147+
types = Keyword.get(opts, :types)
148+
default_format = if types, do: "RowBinary", else: "RowBinaryWithNamesAndTypes"
149+
format = Keyword.get(opts, :format) || default_format
150+
151+
boundary = "ChFormBoundary" <> Base.url_encode64(:crypto.strong_rand_bytes(24))
152+
content_type = "multipart/form-data; boundary=\"#{boundary}\""
153+
enc_boundary = "--#{boundary}\r\n"
154+
multipart = multipart_params(params, enc_boundary)
155+
multipart = add_multipart_part(multipart, "query", statement, enc_boundary)
156+
multipart = [multipart | "--#{boundary}--\r\n"]
157+
158+
{_no_query_params = [],
159+
[{"x-clickhouse-format", format}, {"content-type", content_type} | headers(opts)], multipart}
160+
end
161+
131162
def encode(%Query{statement: statement}, params, opts) do
132163
types = Keyword.get(opts, :types)
133164
default_format = if types, do: "RowBinary", else: "RowBinaryWithNamesAndTypes"
134165
format = Keyword.get(opts, :format) || default_format
135166
{query_params(params), [{"x-clickhouse-format", format} | headers(opts)], statement}
136167
end
137168

169+
defp multipart_params(params, boundary) when is_map(params) do
170+
multipart_named_params(Map.to_list(params), boundary, [])
171+
end
172+
173+
defp multipart_params(params, boundary) when is_list(params) do
174+
multipart_positional_params(params, 0, boundary, [])
175+
end
176+
177+
defp multipart_named_params([{name, value} | params], boundary, acc) do
178+
acc =
179+
add_multipart_part(
180+
acc,
181+
"param_" <> URI.encode_www_form(name),
182+
encode_param(value),
183+
boundary
184+
)
185+
186+
multipart_named_params(params, boundary, acc)
187+
end
188+
189+
defp multipart_named_params([], _boundary, acc), do: acc
190+
191+
defp multipart_positional_params([value | params], idx, boundary, acc) do
192+
acc =
193+
add_multipart_part(
194+
acc,
195+
"param_$" <> Integer.to_string(idx),
196+
encode_param(value),
197+
boundary
198+
)
199+
200+
multipart_positional_params(params, idx + 1, boundary, acc)
201+
end
202+
203+
defp multipart_positional_params([], _idx, _boundary, acc), do: acc
204+
205+
@compile inline: [add_multipart_part: 4]
206+
defp add_multipart_part(multipart, name, value, boundary) do
207+
part = [
208+
boundary,
209+
"content-disposition: form-data; name=\"",
210+
name,
211+
"\"\r\n\r\n",
212+
value,
213+
"\r\n"
214+
]
215+
216+
case multipart do
217+
[] -> part
218+
_ -> [multipart | part]
219+
end
220+
end
221+
138222
defp format_row_binary?(statement) when is_binary(statement) do
139223
statement |> String.trim_trailing() |> String.ends_with?("RowBinary")
140224
end

test/ch/aggregation_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
defmodule Ch.AggregationTest do
2-
use ExUnit.Case
2+
use ExUnit.Case, async: true
33

44
setup do
55
conn = start_supervised!({Ch, database: Ch.Test.database()})

0 commit comments

Comments
 (0)