click_house-client 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +86 -7
- data/lib/click_house/client/arel_extensions/nodes/settings.rb +15 -0
- data/lib/click_house/client/arel_visitor.rb +18 -0
- data/lib/click_house/client/configuration.rb +18 -1
- data/lib/click_house/client/formatter.rb +11 -4
- data/lib/click_house/client/query.rb +3 -3
- data/lib/click_house/client/query_builder.rb +118 -4
- data/lib/click_house/client/query_like.rb +1 -1
- data/lib/click_house/client/to_redacted_sql_visitor.rb +9 -16
- data/lib/click_house/client/version.rb +1 -1
- data/lib/click_house/client.rb +1 -2
- metadata +3 -3
- data/lib/click_house/client/bind_index_manager.rb +0 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 839c38cadbcf3638fc513c949c9f7d1b40453c92c0274e32db1d1e9da86df156
|
|
4
|
+
data.tar.gz: f23d52098f04a9cdb460bf0d8479b19947641546b37ccd85519eeeaba3e59eb6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3c6f54deb213de89f6a5e2e5ad3cccf3e4b3412eb2b22624dc4fda38a41e8a21555736fa7b453fc2e7a07c1cf9fe6f07ef5f5b7bef0aee58019f0e7faa77604c
|
|
7
|
+
data.tar.gz: 2bbbcf502cba7cb5ed185674afd8eae30a28173ab599b2b00f582a21ed65a140b2d92b0ed523ad1dad192c35fbc0a5eb19ec98a3c815f7212f4f98d739795a53
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
click_house-client (0.
|
|
4
|
+
click_house-client (0.13.0)
|
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
|
7
7
|
addressable (~> 2.8)
|
|
@@ -136,7 +136,7 @@ CHECKSUMS
|
|
|
136
136
|
benchmark (0.4.1) sha256=d4ef40037bba27f03b28013e219b950b82bace296549ec15a78016552f8d2cce
|
|
137
137
|
bigdecimal (3.2.2) sha256=39085f76b495eb39a79ce07af716f3a6829bc35eb44f2195e2753749f2fa5adc
|
|
138
138
|
byebug (12.0.0) sha256=d4a150d291cca40b66ec9ca31f754e93fed8aa266a17335f71bb0afa7fca1a1e
|
|
139
|
-
click_house-client (0.
|
|
139
|
+
click_house-client (0.13.0)
|
|
140
140
|
concurrent-ruby (1.3.5) sha256=813b3e37aca6df2a21a3b9f1d497f8cbab24a2b94cab325bffe65ee0f6cbebc6
|
|
141
141
|
connection_pool (2.5.3) sha256=cfd74a82b9b094d1ce30c4f1a346da23ee19dc8a062a16a85f58eab1ced4305b
|
|
142
142
|
diff-lcs (1.5.0) sha256=49b934001c8c6aedb37ba19daec5c634da27b318a7a3c654ae979d6ba1929b67
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ClickHouse::Client
|
|
2
2
|
|
|
3
|
-
This Gem provides a simple way to query ClickHouse databases using the HTTP interface.
|
|
3
|
+
This Gem provides a simple way to query ClickHouse databases using the HTTP interface.
|
|
4
4
|
|
|
5
5
|
## Example usage
|
|
6
6
|
|
|
@@ -61,6 +61,21 @@ puts ClickHouse::Client.execute('CREATE TABLE IF NOT EXISTS t1 (id Int64) ENGINE
|
|
|
61
61
|
puts ClickHouse::Client.execute('DROP TABLE IF EXISTS t1', :main)
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
## Custom typecasters
|
|
65
|
+
|
|
66
|
+
Values returned by `ClickHouse::Client.select` are typecast based on the column type using built-in typecasters (integers, floats, dates, datetimes, intervals). You can register custom typecasters which take precedence over the built-in ones:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
ClickHouse::Client.configure do |config|
|
|
70
|
+
# Parse the value of `*_json` String columns as JSON
|
|
71
|
+
config.register_typecaster('String', ->(meta, value) do
|
|
72
|
+
meta['name'].match?(/\w+_json\z/) ? JSON.parse(value) : value
|
|
73
|
+
end)
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The first argument is the column type: either a `String` for an exact match or a `Regexp` (for example `/\AU?Int\d+\z/`). Wrapper types such as `Nullable(...)`, `LowCardinality(...)` and `SimpleAggregateFunction(...)` are unwrapped before matching. The typecaster receives the column metadata Hash (for example `{ 'name' => 'title', 'type' => 'String' }`) and the raw value. It should handle its own edge cases (for example malformed JSON). `NULL` values are returned as `nil` without invoking any typecaster.
|
|
78
|
+
|
|
64
79
|
## ClickHouse::Client::QueryBuilder
|
|
65
80
|
|
|
66
81
|
The QueryBuilder provides an ActiveRecord-like interface for constructing ClickHouse queries programmatically. While similar to ActiveRecord's query interface, it has been tailored specifically for ClickHouse's SQL dialect and features.
|
|
@@ -182,6 +197,24 @@ query
|
|
|
182
197
|
# => "SELECT * FROM `users` WHERE `users`.`active` = 'true' GROUP BY `users`.`department` HAVING `users`.`avg_salary` > 50000"
|
|
183
198
|
```
|
|
184
199
|
|
|
200
|
+
### Reversing Order
|
|
201
|
+
|
|
202
|
+
The `reverse_order` method inverts the direction of all `ORDER BY` clauses (`ASC` becomes `DESC` and vice versa).
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
query.order(:name, :desc).order(:id).reverse_order.to_sql
|
|
206
|
+
# => "SELECT * FROM `users` ORDER BY `users`.`name` ASC, `users`.`id` DESC"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Empty Result Set
|
|
210
|
+
|
|
211
|
+
Use `.none` to get a query that yields no rows. It appends an always-false `1 = 0` condition to the `WHERE` clause and stays chainable with other query builder methods:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
query.none.where(active: true).to_sql
|
|
215
|
+
# => "SELECT * FROM `users` WHERE 1 = 0 AND `users`.`active` = 'true'"
|
|
216
|
+
```
|
|
217
|
+
|
|
185
218
|
### FINAL Modifier
|
|
186
219
|
|
|
187
220
|
ClickHouse's `FINAL` modifier forces merging of rows during query time for tables in the MergeTree family. Apply it via `.final`:
|
|
@@ -198,6 +231,28 @@ query.final.where(active: true).to_sql
|
|
|
198
231
|
|
|
199
232
|
`FINAL` is currently applied only to the main `FROM` table. When joining, joined tables are not marked `FINAL`. Calling `.final` multiple times is idempotent.
|
|
200
233
|
|
|
234
|
+
### Query Settings
|
|
235
|
+
|
|
236
|
+
Use `.settings` to append a ClickHouse
|
|
237
|
+
[`SETTINGS`](https://clickhouse.com/docs/en/sql-reference/statements/select#settings-in-select-query)
|
|
238
|
+
clause to the query. Chained calls merge, with later values overriding earlier ones.
|
|
239
|
+
Booleans are rendered as `1` / `0` and strings are quoted.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
query.where(active: true).settings(max_execution_time: 60, use_query_cache: true).limit(10).to_sql
|
|
243
|
+
# => "SELECT * FROM `users` WHERE `users`.`active` = 'true' LIMIT 10 SETTINGS max_execution_time = 60, use_query_cache = 1"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Settings belong to the query they are set on. When that builder is embedded as a
|
|
247
|
+
subquery, CTE, `UNION` member or `{var:Subquery}` placeholder, its `SETTINGS` clause is
|
|
248
|
+
rendered inside that part of the query, and ClickHouse applies it there:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
subquery = ClickHouse::Client::QueryBuilder.new('orders').select(:user_id).settings(max_threads: 2)
|
|
252
|
+
query.where(id: subquery).to_sql
|
|
253
|
+
# => "SELECT * FROM `users` WHERE `users`.`id` IN (SELECT `orders`.`user_id` FROM `orders` SETTINGS max_threads = 2)"
|
|
254
|
+
```
|
|
255
|
+
|
|
201
256
|
### Working with JOINs
|
|
202
257
|
|
|
203
258
|
`#joins` supports `INNER JOIN` (default) and `LEFT OUTER JOIN` via
|
|
@@ -260,6 +315,30 @@ query_builder.with(inner.as_cte(:a)).with(inner.as_cte(:b)).to_sql
|
|
|
260
315
|
(build it via `#as_cte`), and raises if the same CTE name is declared more than
|
|
261
316
|
once.
|
|
262
317
|
|
|
318
|
+
### UNION
|
|
319
|
+
|
|
320
|
+
Use `#union` to combine the result sets of two or more queries. The combined
|
|
321
|
+
queries are wrapped as an aliased subquery, so further chaining (`where`,
|
|
322
|
+
`order`, `limit`, ...) applies to the outer `SELECT`.
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
query1 = ClickHouse::Client::QueryBuilder.new('users_2023').select(:id)
|
|
326
|
+
query2 = ClickHouse::Client::QueryBuilder.new('users_2024').select(:id)
|
|
327
|
+
|
|
328
|
+
query1.union(query2).to_sql
|
|
329
|
+
# => "SELECT * FROM ( SELECT `users_2023`.`id` FROM `users_2023` UNION DISTINCT SELECT `users_2024`.`id` FROM `users_2024` ) `union_subquery`"
|
|
330
|
+
|
|
331
|
+
# UNION ALL with a custom subquery alias, chaining on the union result
|
|
332
|
+
query1.union(query2, type: :all, alias_name: 'users').where(id: [1, 2]).to_sql
|
|
333
|
+
# => "SELECT * FROM ( ... UNION ALL ... ) `users` WHERE `users`.`id` IN (1, 2)"
|
|
334
|
+
|
|
335
|
+
# More than two queries can be combined in a single call
|
|
336
|
+
query1.union(query2, query3, type: :all)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`#union` returns a new `QueryBuilder` (immutable) and raises if given anything
|
|
340
|
+
other than `QueryBuilder` instances or an unknown `type`.
|
|
341
|
+
|
|
263
342
|
### Complete Example
|
|
264
343
|
|
|
265
344
|
Here's a comprehensive example combining multiple QueryBuilder features:
|
|
@@ -287,12 +366,12 @@ result = ClickHouse::Client::QueryBuilder.new('users')
|
|
|
287
366
|
.limit(10)
|
|
288
367
|
|
|
289
368
|
puts result.to_sql
|
|
290
|
-
"SELECT `users`.`department`, COUNT(*) AS user_count FROM `users` WHERE `users`.`active` = 'true'
|
|
291
|
-
AND `users`.`department` IN ('Sales', 'Marketing', 'Engineering')
|
|
292
|
-
AND `users`.`id` IN (SELECT `orders`.`user_id` FROM `orders` WHERE `orders`.`status` = 'completed'
|
|
293
|
-
AND `users`.`created_at` > '2025-08-12')
|
|
294
|
-
AND `users`.`email` ILIKE '%@company.com'
|
|
295
|
-
GROUP BY department HAVING COUNT(*) AS user_count > 5
|
|
369
|
+
"SELECT `users`.`department`, COUNT(*) AS user_count FROM `users` WHERE `users`.`active` = 'true'
|
|
370
|
+
AND `users`.`department` IN ('Sales', 'Marketing', 'Engineering')
|
|
371
|
+
AND `users`.`id` IN (SELECT `orders`.`user_id` FROM `orders` WHERE `orders`.`status` = 'completed'
|
|
372
|
+
AND `users`.`created_at` > '2025-08-12')
|
|
373
|
+
AND `users`.`email` ILIKE '%@company.com'
|
|
374
|
+
GROUP BY department HAVING COUNT(*) AS user_count > 5
|
|
296
375
|
ORDER BY user_count DESC LIMIT 10"
|
|
297
376
|
```
|
|
298
377
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'arel'
|
|
4
|
+
|
|
5
|
+
module ClickHouse
|
|
6
|
+
module Client
|
|
7
|
+
module ArelExtensions
|
|
8
|
+
module Nodes
|
|
9
|
+
# Holds the settings hash rendered as a trailing ClickHouse `SETTINGS` clause.
|
|
10
|
+
class Settings < ::Arel::Nodes::Unary
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -30,12 +30,30 @@ module ClickHouse
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# ClickHouse rejects a bare `UNION`; the mode must be explicit.
|
|
34
|
+
def visit_Arel_Nodes_Union(object, collector)
|
|
35
|
+
infix_value_with_paren(object, collector, " UNION DISTINCT ")
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
def visit_ClickHouse_Client_ArelExtensions_Nodes_Final(object, collector)
|
|
34
39
|
collector = visit(object.expr, collector)
|
|
35
40
|
collector << " FINAL"
|
|
36
41
|
end
|
|
37
42
|
|
|
43
|
+
def visit_ClickHouse_Client_ArelExtensions_Nodes_Settings(object, collector)
|
|
44
|
+
rendered = object.expr.map { |name, value| "#{name} = #{quote_setting_value(value)}" }
|
|
45
|
+
collector << "SETTINGS #{rendered.join(', ')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
38
48
|
# rubocop:enable Naming/MethodName
|
|
49
|
+
|
|
50
|
+
def quote_setting_value(value)
|
|
51
|
+
case value
|
|
52
|
+
when true then '1'
|
|
53
|
+
when false then '0'
|
|
54
|
+
else ClickHouse::Client::Quoting.quote(value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
39
57
|
end
|
|
40
58
|
end
|
|
41
59
|
end
|
|
@@ -21,6 +21,12 @@ module ClickHouse
|
|
|
21
21
|
# *logger*: object for receiving logger commands. Default `$stdout`
|
|
22
22
|
# *log_proc*: any output (e.g. structure) to wrap around the query for every statement
|
|
23
23
|
#
|
|
24
|
+
# *register_typecaster* (method): registers a custom typecaster for a column type,
|
|
25
|
+
# taking precedence over the built-in typecasters. Arguments:
|
|
26
|
+
# - type: column type as a String (exact match) or a Regexp
|
|
27
|
+
# - typecaster: a callable object receiving the column metadata Hash
|
|
28
|
+
# (e.g. { 'name' => 'title', 'type' => 'String' }) and the raw value
|
|
29
|
+
#
|
|
24
30
|
# Example:
|
|
25
31
|
#
|
|
26
32
|
# ClickHouse::Client.configure do |c|
|
|
@@ -51,12 +57,17 @@ module ClickHouse
|
|
|
51
57
|
# end
|
|
52
58
|
#
|
|
53
59
|
# c.json_parser = JSON
|
|
60
|
+
#
|
|
61
|
+
# c.register_typecaster('String', ->(meta, value) do
|
|
62
|
+
# meta['name'].match?(/\w+_json\z/) ? JSON.parse(value) : value
|
|
63
|
+
# end)
|
|
54
64
|
# end
|
|
55
65
|
attr_accessor :http_post_proc, :json_parser, :logger, :log_proc
|
|
56
|
-
attr_reader :databases
|
|
66
|
+
attr_reader :databases, :typecasters
|
|
57
67
|
|
|
58
68
|
def initialize
|
|
59
69
|
@databases = {}
|
|
70
|
+
@typecasters = {}
|
|
60
71
|
@http_post_proc = nil
|
|
61
72
|
@json_parser = JSON
|
|
62
73
|
@logger = ::Logger.new($stdout)
|
|
@@ -69,6 +80,12 @@ module ClickHouse
|
|
|
69
80
|
@databases[name] = Database.new(**args)
|
|
70
81
|
end
|
|
71
82
|
|
|
83
|
+
def register_typecaster(type, typecaster)
|
|
84
|
+
raise ConfigurationError, "The typecaster for '#{type}' is already registered" if @typecasters.key?(type)
|
|
85
|
+
|
|
86
|
+
@typecasters[type] = typecaster
|
|
87
|
+
end
|
|
88
|
+
|
|
72
89
|
def validate!
|
|
73
90
|
raise ConfigurationError, "The 'http_post_proc' option is not configured" unless @http_post_proc
|
|
74
91
|
raise ConfigurationError, "The 'json_parser' option is not configured" unless @json_parser
|
|
@@ -19,9 +19,9 @@ module ClickHouse
|
|
|
19
19
|
"IntervalMillisecond" => ->(value) { ActiveSupport::Duration.build(value.to_i / 1000.0) }
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
def self.format(result)
|
|
22
|
+
def self.format(result, custom_typecasters = {})
|
|
23
23
|
column_typecasters = result['meta'].each_with_object({}) do |column, hash|
|
|
24
|
-
hash[column['name']] =
|
|
24
|
+
hash[column['name']] = build_typecaster(column, custom_typecasters)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
result['data'].map do |row|
|
|
@@ -31,7 +31,14 @@ module ClickHouse
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
def self.
|
|
34
|
+
def self.build_typecaster(column, custom_typecasters)
|
|
35
|
+
custom_caster = get_typecaster(column['type'], custom_typecasters)
|
|
36
|
+
return ->(value) { custom_caster.call(column, value) } if custom_caster
|
|
37
|
+
|
|
38
|
+
get_typecaster(column['type']) || DEFAULT
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.get_typecaster(column_type, typecasters = TYPE_CASTERS)
|
|
35
42
|
return unless column_type
|
|
36
43
|
|
|
37
44
|
inner_type = column_type
|
|
@@ -39,7 +46,7 @@ module ClickHouse
|
|
|
39
46
|
.sub(/\ALowCardinality\((.+)\)\z/, '\1') # e.g. LowCardinality(String)
|
|
40
47
|
.sub(/\ASimpleAggregateFunction\(.+,\s*(.+)\)\z/, '\1') # e.g. SimpleAggregateFunction(sum, UInt64)
|
|
41
48
|
|
|
42
|
-
|
|
49
|
+
typecasters.each do |key, caster|
|
|
43
50
|
return caster if key.is_a?(String) ? key == inner_type : key.match?(inner_type)
|
|
44
51
|
end
|
|
45
52
|
|
|
@@ -51,14 +51,14 @@ module ClickHouse
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def to_redacted_sql
|
|
54
|
+
def to_redacted_sql
|
|
55
55
|
raw_query.gsub(PLACEHOLDER_REGEX) do |placeholder_in_query|
|
|
56
56
|
value = placeholder_value(placeholder_in_query)
|
|
57
57
|
|
|
58
58
|
if value.is_a?(QueryLike)
|
|
59
|
-
value.to_redacted_sql
|
|
59
|
+
value.to_redacted_sql
|
|
60
60
|
else
|
|
61
|
-
|
|
61
|
+
'?'
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'active_record'
|
|
4
4
|
require_relative 'arel_extensions/nodes/final'
|
|
5
|
+
require_relative 'arel_extensions/nodes/settings'
|
|
5
6
|
|
|
6
7
|
module ClickHouse
|
|
7
8
|
module Client
|
|
@@ -30,11 +31,16 @@ module ClickHouse
|
|
|
30
31
|
Arel::Nodes::As
|
|
31
32
|
].freeze
|
|
32
33
|
|
|
34
|
+
SETTING_NAME_REGEX = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
35
|
+
|
|
33
36
|
delegate :[], to: :table
|
|
34
37
|
|
|
35
38
|
def initialize(table, alias_name = nil)
|
|
36
|
-
@table =
|
|
39
|
+
@table = case table
|
|
40
|
+
when self.class # subquery
|
|
37
41
|
Arel::Nodes::TableAlias.new(table.to_arel, alias_name)
|
|
42
|
+
when Arel::Nodes::TableAlias
|
|
43
|
+
table
|
|
38
44
|
else
|
|
39
45
|
Arel::Table.new(table)
|
|
40
46
|
end
|
|
@@ -144,6 +150,30 @@ module ClickHouse
|
|
|
144
150
|
end
|
|
145
151
|
end
|
|
146
152
|
|
|
153
|
+
# Inverts the direction of all ORDER BY clauses: ASC becomes DESC and
|
|
154
|
+
# vice versa.
|
|
155
|
+
#
|
|
156
|
+
# @example
|
|
157
|
+
# query.order(:id).reverse_order.to_sql
|
|
158
|
+
# # => "SELECT * FROM `test_table` ORDER BY `test_table`.`id` DESC"
|
|
159
|
+
#
|
|
160
|
+
# @raise [ArgumentError] if any ordering node has no explicit direction
|
|
161
|
+
# @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
|
|
162
|
+
def reverse_order
|
|
163
|
+
clone.tap do |new_instance|
|
|
164
|
+
new_instance.manager.ast.orders = new_instance.manager.ast.orders.map do |order|
|
|
165
|
+
case order
|
|
166
|
+
when Arel::Nodes::Ascending
|
|
167
|
+
Arel::Nodes::Descending.new(order.expr)
|
|
168
|
+
when Arel::Nodes::Descending
|
|
169
|
+
Arel::Nodes::Ascending.new(order.expr)
|
|
170
|
+
else
|
|
171
|
+
raise ArgumentError, "Cannot reverse ordering node without explicit direction: #{order.class.name}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
147
177
|
def group(*columns)
|
|
148
178
|
clone.tap do |new_instance|
|
|
149
179
|
new_instance.manager.group(*columns)
|
|
@@ -182,6 +212,50 @@ module ClickHouse
|
|
|
182
212
|
end
|
|
183
213
|
end
|
|
184
214
|
|
|
215
|
+
# Returns a query that yields no rows.
|
|
216
|
+
#
|
|
217
|
+
# @example
|
|
218
|
+
# query.none.to_sql
|
|
219
|
+
# # => "SELECT * FROM `test_table` WHERE 1 = 0"
|
|
220
|
+
#
|
|
221
|
+
# @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
|
|
222
|
+
def none
|
|
223
|
+
clone.tap do |new_instance|
|
|
224
|
+
new_instance.manager.where(
|
|
225
|
+
Arel.sql('1 = 0')
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Appends a ClickHouse `SETTINGS` clause to the end of the query.
|
|
231
|
+
# See https://clickhouse.com/docs/en/sql-reference/statements/select#settings-in-select-query
|
|
232
|
+
#
|
|
233
|
+
# Chained calls merge settings; a later value overrides an earlier one.
|
|
234
|
+
# Boolean values are rendered as 1 / 0 and strings are quoted.
|
|
235
|
+
#
|
|
236
|
+
# Settings are part of the query AST, so they are also rendered when the
|
|
237
|
+
# builder is embedded as a subquery, CTE or UNION member, and ClickHouse
|
|
238
|
+
# applies them to that part of the query.
|
|
239
|
+
#
|
|
240
|
+
# @example
|
|
241
|
+
# query.settings(max_execution_time: 60, use_query_cache: true).to_sql
|
|
242
|
+
# # => "SELECT * FROM `test_table` SETTINGS max_execution_time = 60, use_query_cache = 1"
|
|
243
|
+
#
|
|
244
|
+
# @param new_settings [Hash{Symbol, String => Integer, Float, String, Symbol, Boolean}]
|
|
245
|
+
# @raise [ArgumentError] if a setting name or value is invalid
|
|
246
|
+
# @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
|
|
247
|
+
def settings(new_settings)
|
|
248
|
+
new_settings.each { |name, value| validate_setting!(name, value) }
|
|
249
|
+
|
|
250
|
+
clone.tap do |new_instance|
|
|
251
|
+
ast = new_instance.manager.ast
|
|
252
|
+
merged = (ast.lock&.expr || {}).merge(new_settings.transform_keys(&:to_s)).freeze
|
|
253
|
+
# ClickHouse has no row locking, so the `lock` slot (rendered after LIMIT / OFFSET)
|
|
254
|
+
# is free to carry the SETTINGS clause.
|
|
255
|
+
ast.lock = ArelExtensions::Nodes::Settings.new(merged)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
185
259
|
def from(subquery, alias_name)
|
|
186
260
|
clone.tap do |new_instance|
|
|
187
261
|
new_from = if subquery.is_a?(self.class)
|
|
@@ -238,6 +312,31 @@ module ClickHouse
|
|
|
238
312
|
end
|
|
239
313
|
end
|
|
240
314
|
|
|
315
|
+
# Combines this query with one or more other queries using UNION and
|
|
316
|
+
# returns a new builder that selects from the combined result as an
|
|
317
|
+
# aliased subquery, so further chaining (#where, #order, #limit, ...)
|
|
318
|
+
# applies to the outer SELECT.
|
|
319
|
+
#
|
|
320
|
+
# @param others [Array<ClickHouse::Client::QueryBuilder>] queries to combine with
|
|
321
|
+
# @param type [Symbol] :distinct or :all
|
|
322
|
+
# @param alias_name [String, Symbol] alias for the union subquery
|
|
323
|
+
# @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
|
|
324
|
+
def union(*others, type: :distinct, alias_name: 'union_subquery')
|
|
325
|
+
validate_union_type!(type)
|
|
326
|
+
raise ArgumentError, 'at least one query to union with is required' if others.empty?
|
|
327
|
+
|
|
328
|
+
others.each do |other|
|
|
329
|
+
raise ArgumentError, "expected #{self.class}, got #{other.class}" unless other.is_a?(self.class)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
node_class = type == :all ? Arel::Nodes::UnionAll : Arel::Nodes::Union
|
|
333
|
+
union_node = others.reduce(to_arel.clone.ast) do |left, other|
|
|
334
|
+
node_class.new(left, other.to_arel.clone.ast)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
self.class.new(Arel::Nodes::TableAlias.new(union_node, alias_name.to_s))
|
|
338
|
+
end
|
|
339
|
+
|
|
241
340
|
# Adds a JOIN clause. Pass `type: :outer` for `LEFT OUTER JOIN`.
|
|
242
341
|
# To join a subquery, pre-alias it via `QueryBuilder.new(sub, 'x').table`
|
|
243
342
|
# or `sub.to_arel.as('x')` and pass that.
|
|
@@ -423,9 +522,8 @@ module ClickHouse
|
|
|
423
522
|
visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
|
|
424
523
|
end
|
|
425
524
|
|
|
426
|
-
def to_redacted_sql
|
|
427
|
-
visitor = ClickHouse::Client::ToRedactedSqlVisitor.new(ClickHouse::Client::ArelEngine.new
|
|
428
|
-
bind_manager: bind_index_manager)
|
|
525
|
+
def to_redacted_sql
|
|
526
|
+
visitor = ClickHouse::Client::ToRedactedSqlVisitor.new(ClickHouse::Client::ArelEngine.new)
|
|
429
527
|
visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
|
|
430
528
|
end
|
|
431
529
|
|
|
@@ -435,6 +533,16 @@ module ClickHouse
|
|
|
435
533
|
|
|
436
534
|
private
|
|
437
535
|
|
|
536
|
+
def validate_setting!(name, value)
|
|
537
|
+
unless (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.match?(SETTING_NAME_REGEX)
|
|
538
|
+
raise ArgumentError, "Invalid setting name '#{name}'"
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
return if [Integer, Float, String, Symbol, TrueClass, FalseClass].any? { |klass| value.is_a?(klass) }
|
|
542
|
+
|
|
543
|
+
raise ArgumentError, "Invalid value for setting '#{name}': #{value.class.name}"
|
|
544
|
+
end
|
|
545
|
+
|
|
438
546
|
def normalize_operand(operand)
|
|
439
547
|
case operand
|
|
440
548
|
when Arel::Expressions
|
|
@@ -499,6 +607,12 @@ module ClickHouse
|
|
|
499
607
|
raise ArgumentError, "Invalid order direction '#{direction}'. Must be :asc or :desc"
|
|
500
608
|
end
|
|
501
609
|
|
|
610
|
+
def validate_union_type!(type)
|
|
611
|
+
return if %i[distinct all].include?(type)
|
|
612
|
+
|
|
613
|
+
raise ArgumentError, "Invalid union type '#{type}'. Must be :distinct or :all"
|
|
614
|
+
end
|
|
615
|
+
|
|
502
616
|
def validate_join_type!(type)
|
|
503
617
|
return if %i[inner outer].include?(type)
|
|
504
618
|
|
|
@@ -11,7 +11,7 @@ module ClickHouse
|
|
|
11
11
|
# Redacted version of the SQL query generated by the to_sql method where the
|
|
12
12
|
# placeholders are stripped. These queries are meant to be exported to external
|
|
13
13
|
# log aggregation systems.
|
|
14
|
-
def to_redacted_sql
|
|
14
|
+
def to_redacted_sql
|
|
15
15
|
raise NotImplementedError
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -8,15 +8,8 @@ module ClickHouse
|
|
|
8
8
|
# query_builder = ClickHouse::QueryBuilder.new('users').where(name: 'John Doe')
|
|
9
9
|
# redacted_query = query_builder.to_redacted_sql
|
|
10
10
|
# # The redacted_query will contain the SQL query with values replaced by placeholders.
|
|
11
|
-
# output: "SELECT * FROM \"users\" WHERE \"users\".\"name\" =
|
|
11
|
+
# output: "SELECT * FROM \"users\" WHERE \"users\".\"name\" = ?"
|
|
12
12
|
class ToRedactedSqlVisitor < ArelVisitor
|
|
13
|
-
attr_reader :bind_manager
|
|
14
|
-
|
|
15
|
-
def initialize(*args, bind_manager: ClickHouse::Client::BindIndexManager.new)
|
|
16
|
-
@bind_manager = bind_manager
|
|
17
|
-
super(*args)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
13
|
private
|
|
21
14
|
|
|
22
15
|
def redaction_enabled?
|
|
@@ -80,11 +73,9 @@ module ClickHouse
|
|
|
80
73
|
|
|
81
74
|
case redacted_o.name
|
|
82
75
|
when 'startsWith'
|
|
83
|
-
redacted_o.expressions[1] =
|
|
76
|
+
redacted_o.expressions[1] = bind_param
|
|
84
77
|
else
|
|
85
|
-
redacted_o.expressions = redacted_o.expressions.map
|
|
86
|
-
Arel.sql(bind_manager.next_bind_str)
|
|
87
|
-
end
|
|
78
|
+
redacted_o.expressions = redacted_o.expressions.map { bind_param }
|
|
88
79
|
end
|
|
89
80
|
|
|
90
81
|
super(redacted_o, collector)
|
|
@@ -100,17 +91,19 @@ module ClickHouse
|
|
|
100
91
|
cloned_o = o.clone
|
|
101
92
|
|
|
102
93
|
redacted_right = if o.right.is_a?(Array)
|
|
103
|
-
Array.new(o.right.size)
|
|
104
|
-
Arel.sql(bind_manager.next_bind_str)
|
|
105
|
-
end
|
|
94
|
+
Array.new(o.right.size) { bind_param }
|
|
106
95
|
else
|
|
107
|
-
|
|
96
|
+
bind_param
|
|
108
97
|
end
|
|
109
98
|
|
|
110
99
|
cloned_o.right = redacted_right
|
|
111
100
|
cloned_o
|
|
112
101
|
end
|
|
113
102
|
|
|
103
|
+
def bind_param
|
|
104
|
+
Arel::Nodes::BindParam.new('?')
|
|
105
|
+
end
|
|
106
|
+
|
|
114
107
|
# rubocop:enable Naming/MethodName
|
|
115
108
|
# rubocop:enable Naming/MethodParameterName
|
|
116
109
|
end
|
data/lib/click_house/client.rb
CHANGED
|
@@ -10,7 +10,6 @@ require 'active_support/core_ext/object/blank'
|
|
|
10
10
|
require_relative "client/version"
|
|
11
11
|
require_relative "client/database"
|
|
12
12
|
require_relative "client/configuration"
|
|
13
|
-
require_relative "client/bind_index_manager"
|
|
14
13
|
require_relative "client/quoting"
|
|
15
14
|
require_relative "client/arel_engine"
|
|
16
15
|
require_relative "client/query_like"
|
|
@@ -50,7 +49,7 @@ module ClickHouse
|
|
|
50
49
|
|
|
51
50
|
instrument[:statistics] = parsed_response['statistics']&.symbolize_keys
|
|
52
51
|
|
|
53
|
-
Formatter.format(parsed_response)
|
|
52
|
+
Formatter.format(parsed_response, configuration.typecasters)
|
|
54
53
|
end
|
|
55
54
|
end
|
|
56
55
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: click_house-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- group::optimize
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -186,8 +186,8 @@ files:
|
|
|
186
186
|
- lib/click_house/client.rb
|
|
187
187
|
- lib/click_house/client/arel_engine.rb
|
|
188
188
|
- lib/click_house/client/arel_extensions/nodes/final.rb
|
|
189
|
+
- lib/click_house/client/arel_extensions/nodes/settings.rb
|
|
189
190
|
- lib/click_house/client/arel_visitor.rb
|
|
190
|
-
- lib/click_house/client/bind_index_manager.rb
|
|
191
191
|
- lib/click_house/client/configuration.rb
|
|
192
192
|
- lib/click_house/client/database.rb
|
|
193
193
|
- lib/click_house/client/formatter.rb
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module ClickHouse
|
|
4
|
-
module Client
|
|
5
|
-
class BindIndexManager
|
|
6
|
-
def initialize(start_index = 1)
|
|
7
|
-
@current_index = start_index
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def next_bind_str
|
|
11
|
-
bind_str = "$#{@current_index}"
|
|
12
|
-
@current_index += 1
|
|
13
|
-
bind_str
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|