Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2ab8608
multipart requests
hawkyre Jul 11, 2025
76274b7
to false
hawkyre Jul 11, 2025
828133c
update docs and interface
hawkyre Jul 11, 2025
23db2b9
remove to string
hawkyre Jul 11, 2025
66020ff
custom multipart and slight refactor
hawkyre Jul 29, 2025
482a42f
merge
hawkyre Jul 29, 2025
4f11bab
Merge branch 'master' into send-params-in-multipart-form
hawkyre Jul 29, 2025
5191d07
docs
hawkyre Jul 29, 2025
22207b1
doc title
hawkyre Jul 29, 2025
d21d7ab
more cleanup
hawkyre Jul 29, 2025
3bee925
send settings as params
hawkyre Aug 18, 2025
33ecfaf
Bump actions/checkout from 4 to 5 (#270)
dependabot[bot] Aug 18, 2025
574c14b
fix version check (#274)
ruslandoga Aug 26, 2025
a018df8
changelog
ruslandoga Aug 26, 2025
ecc8a19
add older ClickHouse to CI
ruslandoga Aug 26, 2025
cac0abd
tag 'json as string' test as json
ruslandoga Aug 26, 2025
a59d141
release v0.5.5
ruslandoga Aug 26, 2025
e0ccd54
update deps
ruslandoga Aug 26, 2025
b3edc2f
comment on why older version in ci
ruslandoga Aug 26, 2025
27e11d8
shorter cache key
ruslandoga Aug 26, 2025
116322b
fix internal type ordering in Variant (#275)
ruslandoga Aug 26, 2025
d8cb624
release v0.5.6
ruslandoga Aug 26, 2025
e71cb51
Merge branch 'master' into send-params-in-multipart-form
hawkyre Sep 10, 2025
76f6146
Merge branch 'master' into multipart-ruslan
ruslandoga Jan 2, 2026
b28de4e
fewer changes
ruslandoga Jan 2, 2026
fbe5072
eh
ruslandoga Jan 2, 2026
ae596c1
eh x2
ruslandoga Jan 2, 2026
6761c33
readme
ruslandoga Jan 2, 2026
be1f04a
dialyzer
ruslandoga Jan 2, 2026
a86ffff
a few more tests
ruslandoga Jan 2, 2026
0f910e6
more tests
ruslandoga Jan 2, 2026
aafe017
typos skip
ruslandoga Jan 2, 2026
10f1fe1
eh
ruslandoga Jan 2, 2026
5574eef
eh!
ruslandoga Jan 2, 2026
8292590
cleanup
ruslandoga Jan 2, 2026
99522fc
continue
ruslandoga Jan 2, 2026
813e4bc
continue
ruslandoga Jan 3, 2026
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 .typos.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[default.extend-words]
"som" = "som" # ./test/ch/ecto_type_test.exs
"ECT" = "ECT" # ./test/ch/query_test.exs
"Evn" = "Evn" # ./CHANGELOG.md
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
# Changelog

## Unreleased

- 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.

⚠️ This is currently **opt-in** per query ⚠️

Global support for the entire connection pool is planned for a future release.

**Usage**

Pass `multipart: true` in the options list for `Ch.query/4`

```elixir
# Example usage
Ch.query(pool, "SELECT {a:String}, {b:String}", %{"a" => "A", "b" => "B"}, multipart: true)
```

<details>
<summary>View raw request format reference</summary>

```http
POST / HTTP/1.1
content-length: 387
host: localhost:8123
user-agent: ch/0.6.2-dev
x-clickhouse-format: RowBinaryWithNamesAndTypes
content-type: multipart/form-data; boundary="ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw"

--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw
content-disposition: form-data; name="param_a"

A
--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw
content-disposition: form-data; name="param_b"

B
--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw
content-disposition: form-data; name="query"

select {a:String}, {b:String}
--ChFormBoundaryZZlfchKTcd8ToWjEvn66i3lAxNJ_T9dw--
```

</details>

## 0.6.1 (2025-12-04)

- handle disconnect during stream https://github.com/plausible/ch/pull/283
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ Note on datetime encoding in query parameters:
- `%NaiveDateTime{}` is encoded as text to make it assume the column's or ClickHouse server's timezone
- `%DateTime{}` is encoded as unix timestamp and is treated as UTC timestamp by ClickHouse

#### Select rows (lots of params, reverse proxy)

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.

To avoid this, you can use the `multipart: true` option to send the query and parameters in the request body.

```elixir
{:ok, pid} = Ch.start_link()

# Moves parameters from the URL to a multipart/form-data body
%Ch.Result{rows: [[[1, 2, 3 | _rest]]]} =
Ch.query!(pid, "SELECT {ids:Array(UInt64)}", %{"ids" => Enum.to_list(1..10_000)}, multipart: true)
```

> [!NOTE]
>
> `multipart: true` is currently required on each individual query. Support for pool-wide configuration is planned for a future release.

#### Insert rows

```elixir
Expand Down
2 changes: 2 additions & 0 deletions lib/ch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ defmodule Ch do
# TODO remove
| {:encode, boolean}
| {:decode, boolean}
| {:multipart, boolean}
| DBConnection.connection_option()

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

"""
Expand Down
90 changes: 87 additions & 3 deletions lib/ch/query.ex
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
defmodule Ch.Query do
@moduledoc "Query struct wrapping the SQL statement."
defstruct [:statement, :command, :encode, :decode]
defstruct [:statement, :command, :encode, :decode, :multipart]

@type t :: %__MODULE__{statement: iodata, command: command, encode: boolean, decode: boolean}
@type t :: %__MODULE__{
statement: iodata,
command: command,
encode: boolean,
decode: boolean,
multipart: boolean
}

@doc false
@spec build(iodata, [Ch.query_option()]) :: t
def build(statement, opts \\ []) do
command = Keyword.get(opts, :command) || extract_command(statement)
encode = Keyword.get(opts, :encode, true)
decode = Keyword.get(opts, :decode, true)
%__MODULE__{statement: statement, command: command, encode: encode, decode: decode}
multipart = Keyword.get(opts, :multipart, false)

%__MODULE__{
statement: statement,
command: command,
encode: encode,
decode: decode,
multipart: multipart
}
end

statements = [
Expand Down Expand Up @@ -72,6 +86,7 @@ defmodule Ch.Query do
end

defimpl DBConnection.Query, for: Ch.Query do
@dialyzer :no_improper_lists
alias Ch.{Query, Result, RowBinary}

@spec parse(Query.t(), [Ch.query_option()]) :: Query.t()
Expand Down Expand Up @@ -128,13 +143,82 @@ defimpl DBConnection.Query, for: Ch.Query do
end
end

def encode(%Query{multipart: true, 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

def 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

defp multipart_params(params, boundary) when is_map(params) do
multipart_named_params(Map.to_list(params), boundary, [])
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 format_row_binary?(statement) when is_binary(statement) do
statement |> String.trim_trailing() |> String.ends_with?("RowBinary")
end
Expand Down
2 changes: 1 addition & 1 deletion test/ch/aggregation_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
defmodule Ch.AggregationTest do
use ExUnit.Case
use ExUnit.Case, async: true

setup do
conn = start_supervised!({Ch, database: Ch.Test.database()})
Expand Down
Loading