Skip to content

Commit 3d397d7

Browse files
author
Philip Sampaio
authored
Add query function (#5)
* Add `query` function * Refactor to simplify API Now we have `ReqCH.new/1` and `ReqCH.query/3`. * Remove "private" option for query * Apply suggestions from code review Co-authored-by: Wojtek Mach <[email protected]> * Raise instead of given error for wrong format * Add one more documentation section for `query/3`
1 parent 33ad836 commit 3d397d7

File tree

2 files changed

+291
-230
lines changed

2 files changed

+291
-230
lines changed

lib/req_ch.ex

Lines changed: 106 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,117 +3,156 @@ defmodule ReqCH do
33
A Req plugin for ClickHouse.
44
55
By default, `ReqCH` will use TSV as the default output format.
6-
To change that, see the `attach/2` docs for details.
6+
To change that, see the `new/2` docs for details.
77
"""
88

9-
@connection_options [
10-
:database
11-
]
12-
13-
@general_options [
14-
:clickhouse,
9+
@options [
10+
:database,
1511
:format
1612
]
1713

1814
@formats_page "https://clickhouse.com/docs/en/interfaces/formats"
1915
@supported_formats ~w(TabSeparated TabSeparatedRaw TabSeparatedWithNames TabSeparatedWithNamesAndTypes TabSeparatedRawWithNames TabSeparatedRawWithNamesAndTypes Template TemplateIgnoreSpaces CSV CSVWithNames CSVWithNamesAndTypes CustomSeparated CustomSeparatedWithNames CustomSeparatedWithNamesAndTypes SQLInsert Values Vertical JSON JSONAsString JSONAsObject JSONStrings JSONColumns JSONColumnsWithMetadata JSONCompact JSONCompactStrings JSONCompactColumns JSONEachRow PrettyJSONEachRow JSONEachRowWithProgress JSONStringsEachRow JSONStringsEachRowWithProgress JSONCompactEachRow JSONCompactEachRowWithNames JSONCompactEachRowWithNamesAndTypes JSONCompactStringsEachRow JSONCompactStringsEachRowWithNames JSONCompactStringsEachRowWithNamesAndTypes JSONObjectEachRow BSONEachRow TSKV Pretty PrettyNoEscapes PrettyMonoBlock PrettyNoEscapesMonoBlock PrettyCompact PrettyCompactNoEscapes PrettyCompactMonoBlock PrettyCompactNoEscapesMonoBlock PrettySpace PrettySpaceNoEscapes PrettySpaceMonoBlock PrettySpaceNoEscapesMonoBlock Prometheus Protobuf ProtobufSingle ProtobufList Avro AvroConfluent Parquet ParquetMetadata Arrow ArrowStream ORC One Npy RowBinary RowBinaryWithNames RowBinaryWithNamesAndTypes RowBinaryWithDefaults Native Null XML CapnProto LineAsString Regexp RawBLOB MsgPack MySQLDump DWARF Markdown Form)
2016

2117
@doc """
22-
Attach this plugin to a Req Request.
18+
Builds a new `%Req.Request{}` for ClickHouse requests.
2319
2420
## Options
2521
26-
* `:clickhouse` - The query to be performed. If not provided,
27-
the request and response pipelines won't be modified.
22+
This function can receive any option that `Req.new/1` accepts,
23+
plus the ones described below.
24+
25+
To set a new endpoint, use the `:base_url` option from `Req`.
26+
It is by default "http://localhost:8123".
27+
2828
* `:format` - Optional. The format of the response. Default is `:tsv`.
2929
This option accepts `:tsv`, `:csv`, `:json` or `:explorer` as atoms.
3030
3131
It also accepts all formats described in the #{@formats_page} page.
3232
3333
The `:explorer` format is special, and will build an Explorer dataframe
3434
in case the `:explorer` dependency is installed.
35+
3536
* `:database` - Optional. The database to use in the queries.
3637
Default is `nil`.
3738
3839
## Examples
3940
41+
After setting a default database, one can make a request directly:
42+
43+
iex> req = ReqCH.new(database: "system")
44+
iex> Req.post!(req, body: "SELECT number FROM numbers LIMIT 3").body
45+
1\n2\n3\n
46+
47+
It's also possible to make a query using `Req.get/2`:
48+
49+
iex> req = ReqCH.new(database: "system")
50+
iex> Req.get!(req, params: [query: "SELECT number FROM numbers LIMIT 3"]).body
51+
1\n2\n3\n
52+
53+
In case the server needs authentication, it's possible to use `Req` options for that.
54+
55+
iex> req = ReqCH.new(base_url: "http://example.org:8123", auth: {:basic, "user:pass"})
56+
iex> Req.post!(req, body: "SELECT number FROM system.numbers LIMIT 3").body
57+
"0\\n1\\n2\\n"
58+
59+
"""
60+
@spec new(Keyword.t()) :: Req.Request.t()
61+
def new(opts \\ []) do
62+
attach(Req.new(base_url: "http://localhost:8123"), opts)
63+
end
64+
65+
defp attach(%Req.Request{} = req, opts) do
66+
req
67+
|> Req.Request.prepend_request_steps(clickhouse_run: &run/1)
68+
|> Req.Request.register_options(@options)
69+
|> Req.Request.merge_options(opts)
70+
end
71+
72+
defguardp is_query_params(value) when is_list(value) or is_map(value)
73+
74+
@doc """
75+
Performs a query against ClickHouse API.
76+
77+
See docs from `new/1` for details about the options.
78+
79+
## Examples
80+
4081
Queries can be performed using both `Req.get/2` or `Req.post/2`, but GET
4182
is "read-only" and commands like `CREATE` or `INSERT` cannot be used with it.
83+
For that reason, by default we perform a `POST` request.
84+
To change that, use `query/2` with a pre-configured `req`.
4285
4386
A plain query:
4487
45-
iex> req = Req.new() |> ReqCH.attach()
46-
iex> Req.get!(req, clickhouse: "SELECT number FROM system.numbers LIMIT 3").body
88+
iex> {:ok, response} = ReqCH.query("SELECT number FROM system.numbers LIMIT 3")
89+
iex> response.body
4790
"0\\n1\\n2\\n"
4891
4992
Changing the format to `:explorer` will return a dataframe:
5093
51-
iex> req = Req.new() |> ReqCH.attach()
52-
iex> Req.get!(req, clickhouse: "SELECT number FROM system.numbers LIMIT 3", format: :explorer).body
94+
iex> {:ok, response} = ReqCH.query("SELECT number FROM system.numbers LIMIT 3", [], format: :explorer)
95+
iex> response.body
5396
#Explorer.DataFrame<
5497
Polars[3 x 1]
5598
number u64 [0, 1, 2]
5699
>
57100
58101
Using parameters is also possible:
59102
60-
iex> req = Req.new() |> ReqCH.attach(format: :explorer, database: "system")
61-
iex> Req.get!(req, clickhouse: {"SELECT number FROM numbers WHERE number > {num:UInt8} LIMIT 3", [num: 5]}).body
103+
iex> opts = [format: :explorer, database: "system"]
104+
iex> {:ok, response} = ReqCH.query("SELECT number FROM numbers WHERE number > {num:UInt8} LIMIT 3", [num: 5], opts)
62105
#Explorer.DataFrame<
63106
Polars[3 x 1]
64107
number u64 [6, 7, 8]
65108
>
66109
67-
In case a different endpoint is needed, we can use the `:base_url` option
68-
from `Req` to set that value. We can use use `:auth` to pass down the username
69-
and password:
110+
This function can accept `Req` options, as well as mixing them with `ReqCH` options:
70111
71-
iex> req = Req.new(base_url: "https://example.org:8123", auth: {:basic, "user:pass"})
72-
iex> req = ReqCH.attach(req)
73-
iex> Req.post!(req, clickhouse: "SELECT number FROM system.numbers LIMIT 3").body
112+
iex> opts = [base_url: "http://example.org:8123", database: "system", auth: {:basic, "user:pass"}]
113+
iex> {:ok, response} = ReqCH.query("SELECT number FROM numbers LIMIT 3", [], opts)
114+
iex> response.body
74115
"0\\n1\\n2\\n"
75116
76117
"""
77-
def attach(%Req.Request{} = request, opts \\ []) do
78-
request
79-
|> Req.Request.prepend_request_steps(clickhouse_run: &run/1)
80-
|> Req.Request.register_options(@connection_options ++ @general_options)
81-
|> Req.Request.merge_options(opts)
118+
@spec query(sql_query :: binary(), params :: Map.t() | Keyword.t(), opts :: Keyword.t()) ::
119+
{:ok, Req.Response.t()} | {:error, binary()}
120+
def query(sql_query, params \\ [], opts \\ [])
121+
122+
def query(sql_query, params, opts)
123+
when is_binary(sql_query) and is_query_params(params) and is_list(opts) do
124+
opts
125+
|> new()
126+
|> put_params(prepare_params(params))
127+
|> Req.post(body: sql_query)
82128
end
83129

84-
defp run(%Req.Request{private: %{clickhouse_format: _}} = request), do: request
85-
86-
defp run(%Req.Request{options: %{clickhouse: _query}} = request) do
87-
request = update_in(request.options, &Map.put_new(&1, :base_url, "http://localhost:8123"))
88-
89-
request
90-
|> add_format()
91-
|> add_query()
92-
|> maybe_add_database()
93-
|> Req.Request.append_response_steps(clickhouse_result: &handle_clickhouse_result/1)
130+
@doc """
131+
Same as `query/3`, but raises in case of error.
132+
"""
133+
@spec query!(sql_query :: binary(), params :: Map.t() | Keyword.t(), opts :: Keyword.t()) ::
134+
Req.Response.t()
135+
def query!(sql_query, params \\ [], opts \\ [])
136+
137+
def query!(sql_query, params, opts)
138+
when is_binary(sql_query) and is_query_params(params) and is_list(opts) do
139+
case query(sql_query, params, opts) do
140+
{:ok, response} -> response
141+
{:error, exception} -> raise exception
142+
end
94143
end
95144

96-
defp run(%Req.Request{} = request), do: request
97-
98-
defp add_query(%Req.Request{} = request) do
99-
query = Req.Request.fetch_option!(request, :clickhouse)
145+
defp run(%Req.Request{private: %{clickhouse_format: _}} = request), do: request
100146

101-
case query do
102-
{sql, params} when is_binary(sql) and (is_list(params) or is_map(params)) ->
103-
request
104-
|> add_sql_part(sql)
105-
|> put_params(prepare_params(params))
147+
defp run(%Req.Request{} = request) do
148+
request = update_in(request.options, &Map.put_new(&1, :base_url, "http://localhost:8123"))
106149

107-
sql when is_binary(sql) ->
108-
add_sql_part(request, sql)
150+
with %Req.Request{} = req1 <- add_format(request),
151+
%Req.Request{} = req2 <- maybe_add_database(req1) do
152+
Req.Request.append_response_steps(req2, clickhouse_result: &handle_clickhouse_result/1)
109153
end
110154
end
111155

112-
defp add_sql_part(%Req.Request{method: :post} = request, sql), do: %{request | body: sql}
113-
114-
defp add_sql_part(%Req.Request{method: :get} = request, sql),
115-
do: put_params(request, query: sql)
116-
117156
defp put_params(request, params) do
118157
encoded = URI.encode_query(params, :rfc3986)
119158

@@ -127,14 +166,23 @@ defmodule ReqCH do
127166
Enum.map(params, fn {key, value} -> {"param_#{key}", value} end)
128167
end
129168

169+
@valid_formats [:tsv, :csv, :json, :explorer]
170+
130171
defp add_format(%Req.Request{} = request) do
131-
format = request |> Req.Request.get_option(:format, :tsv) |> normalise_format!()
172+
format_option = Req.Request.get_option(request, :format, :tsv)
173+
format = normalise_format(format_option)
132174

133-
format_header = with :explorer <- format, do: "Parquet"
175+
if format do
176+
format_header = with :explorer <- format, do: "Parquet"
134177

135-
request
136-
|> Req.Request.put_private(:clickhouse_format, format)
137-
|> Req.Request.put_header("x-clickhouse-format", format_header)
178+
request
179+
|> Req.Request.put_private(:clickhouse_format, format)
180+
|> Req.Request.put_header("x-clickhouse-format", format_header)
181+
else
182+
raise ArgumentError,
183+
"the given format #{inspect(format_option)} is invalid. Expecting one of #{inspect(@valid_formats)} " <>
184+
"or one of the valid options described in #{@formats_page}"
185+
end
138186
end
139187

140188
defp normalise_format(:tsv), do: "TabSeparated"
@@ -154,17 +202,6 @@ defmodule ReqCH do
154202

155203
defp normalise_format(_), do: nil
156204

157-
@valid_formats [:tsv, :csv, :json, :explorer]
158-
defp normalise_format!(format) do
159-
if valid = normalise_format(format) do
160-
valid
161-
else
162-
raise ArgumentError,
163-
"the given format #{inspect(format)} is invalid. Expecting one of #{inspect(@valid_formats)} " <>
164-
"or one of the valid options described in #{@formats_page}"
165-
end
166-
end
167-
168205
defp maybe_add_database(%Req.Request{} = request) do
169206
if database = Req.Request.get_option(request, :database) do
170207
put_params(request, database: database)

0 commit comments

Comments
 (0)