click_house-client 0.10.0 → 0.12.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 +91 -7
- data/gemfiles/Gemfile-rails-7.2 +3 -0
- data/lib/click_house/client/arel_visitor.rb +5 -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 +125 -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 +2 -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: 5513d15d8bf17295cf98d021b30650f46d0101a6d828b2620499f87a949f2e3d
|
|
4
|
+
data.tar.gz: 27fd28cc07d8b67d8de72031de4e3c7e343cef51a3230afd96ba0adf3d24c252
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3d7bf7328dd7ab42b004e3e35e58c35b4869267e4a2e701f7d29f963c2c445c3d63f0bbd5e45db679cc4d2fda6880ef367aff18e0a04b1a030f54ede2317ead
|
|
7
|
+
data.tar.gz: 40125fc9d75f13be2f1230ea872b72c6d01e797384674064d3fb42c613d1792c827e58d6ccfcd894b783ff4d89b838c04e9f50b2e297f4b085bba5c33b95585e
|
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.11.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.11.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`:
|
|
@@ -233,6 +266,57 @@ query
|
|
|
233
266
|
# => "SELECT * FROM `users` LEFT OUTER JOIN (SELECT `orders`.`id`, `orders`.`user_id` FROM `orders`) `o` ON `users`.`id` = `o`.`user_id`"
|
|
234
267
|
```
|
|
235
268
|
|
|
269
|
+
### Common Table Expressions (CTEs)
|
|
270
|
+
|
|
271
|
+
Use `#as_cte(name)` to wrap a query as a named CTE node, then attach it to a
|
|
272
|
+
main query with `#with(cte)`. The CTE can then be referenced by name in `FROM`
|
|
273
|
+
and IN-subquery positions, which is useful when the same subquery is needed in
|
|
274
|
+
more than one place.
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
inner = ClickHouse::Client::QueryBuilder.new('builds').select(:id, :stage_id)
|
|
278
|
+
query_builder = ClickHouse::Client::QueryBuilder.new('finished_builds').select(:id)
|
|
279
|
+
|
|
280
|
+
query_builder.with(inner.as_cte(:finished_builds)).to_sql
|
|
281
|
+
# => "WITH finished_builds AS (SELECT `builds`.`id`, `builds`.`stage_id` FROM `builds`) SELECT `finished_builds`.`id` FROM `finished_builds`"
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Chained calls accumulate, unlike `Arel::SelectManager#with` which would keep
|
|
285
|
+
only the last CTE:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
query_builder.with(inner.as_cte(:a)).with(inner.as_cte(:b)).to_sql
|
|
289
|
+
# => "WITH a AS (...), b AS (...) SELECT ..."
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`#with` returns a new `QueryBuilder` (immutable), requires an `Arel::Nodes::Cte`
|
|
293
|
+
(build it via `#as_cte`), and raises if the same CTE name is declared more than
|
|
294
|
+
once.
|
|
295
|
+
|
|
296
|
+
### UNION
|
|
297
|
+
|
|
298
|
+
Use `#union` to combine the result sets of two or more queries. The combined
|
|
299
|
+
queries are wrapped as an aliased subquery, so further chaining (`where`,
|
|
300
|
+
`order`, `limit`, ...) applies to the outer `SELECT`.
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
query1 = ClickHouse::Client::QueryBuilder.new('users_2023').select(:id)
|
|
304
|
+
query2 = ClickHouse::Client::QueryBuilder.new('users_2024').select(:id)
|
|
305
|
+
|
|
306
|
+
query1.union(query2).to_sql
|
|
307
|
+
# => "SELECT * FROM ( SELECT `users_2023`.`id` FROM `users_2023` UNION DISTINCT SELECT `users_2024`.`id` FROM `users_2024` ) `union_subquery`"
|
|
308
|
+
|
|
309
|
+
# UNION ALL with a custom subquery alias, chaining on the union result
|
|
310
|
+
query1.union(query2, type: :all, alias_name: 'users').where(id: [1, 2]).to_sql
|
|
311
|
+
# => "SELECT * FROM ( ... UNION ALL ... ) `users` WHERE `users`.`id` IN (1, 2)"
|
|
312
|
+
|
|
313
|
+
# More than two queries can be combined in a single call
|
|
314
|
+
query1.union(query2, query3, type: :all)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`#union` returns a new `QueryBuilder` (immutable) and raises if given anything
|
|
318
|
+
other than `QueryBuilder` instances or an unknown `type`.
|
|
319
|
+
|
|
236
320
|
### Complete Example
|
|
237
321
|
|
|
238
322
|
Here's a comprehensive example combining multiple QueryBuilder features:
|
|
@@ -260,12 +344,12 @@ result = ClickHouse::Client::QueryBuilder.new('users')
|
|
|
260
344
|
.limit(10)
|
|
261
345
|
|
|
262
346
|
puts result.to_sql
|
|
263
|
-
"SELECT `users`.`department`, COUNT(*) AS user_count FROM `users` WHERE `users`.`active` = 'true'
|
|
264
|
-
AND `users`.`department` IN ('Sales', 'Marketing', 'Engineering')
|
|
265
|
-
AND `users`.`id` IN (SELECT `orders`.`user_id` FROM `orders` WHERE `orders`.`status` = 'completed'
|
|
266
|
-
AND `users`.`created_at` > '2025-08-12')
|
|
267
|
-
AND `users`.`email` ILIKE '%@company.com'
|
|
268
|
-
GROUP BY department HAVING COUNT(*) AS user_count > 5
|
|
347
|
+
"SELECT `users`.`department`, COUNT(*) AS user_count FROM `users` WHERE `users`.`active` = 'true'
|
|
348
|
+
AND `users`.`department` IN ('Sales', 'Marketing', 'Engineering')
|
|
349
|
+
AND `users`.`id` IN (SELECT `orders`.`user_id` FROM `orders` WHERE `orders`.`status` = 'completed'
|
|
350
|
+
AND `users`.`created_at` > '2025-08-12')
|
|
351
|
+
AND `users`.`email` ILIKE '%@company.com'
|
|
352
|
+
GROUP BY department HAVING COUNT(*) AS user_count > 5
|
|
269
353
|
ORDER BY user_count DESC LIMIT 10"
|
|
270
354
|
```
|
|
271
355
|
|
data/gemfiles/Gemfile-rails-7.2
CHANGED
|
@@ -5,6 +5,9 @@ source "https://rubygems.org"
|
|
|
5
5
|
gemspec path: ".."
|
|
6
6
|
|
|
7
7
|
gem "rails", "~> 7.2"
|
|
8
|
+
# i18n >= 1.15 calls Fiber#[] (Ruby 3.2+) but declares no required_ruby_version,
|
|
9
|
+
# so it resolves on Ruby 3.1 and then crashes at load.
|
|
10
|
+
gem "i18n", "< 1.15"
|
|
8
11
|
gem "gitlab-styles", "~> 12.0.1"
|
|
9
12
|
gem "rake", "~> 13.0"
|
|
10
13
|
gem "rspec", "~> 3.0"
|
|
@@ -30,6 +30,11 @@ 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"
|
|
@@ -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
|
|
@@ -33,8 +33,11 @@ module ClickHouse
|
|
|
33
33
|
delegate :[], to: :table
|
|
34
34
|
|
|
35
35
|
def initialize(table, alias_name = nil)
|
|
36
|
-
@table =
|
|
36
|
+
@table = case table
|
|
37
|
+
when self.class # subquery
|
|
37
38
|
Arel::Nodes::TableAlias.new(table.to_arel, alias_name)
|
|
39
|
+
when Arel::Nodes::TableAlias
|
|
40
|
+
table
|
|
38
41
|
else
|
|
39
42
|
Arel::Table.new(table)
|
|
40
43
|
end
|
|
@@ -144,6 +147,30 @@ module ClickHouse
|
|
|
144
147
|
end
|
|
145
148
|
end
|
|
146
149
|
|
|
150
|
+
# Inverts the direction of all ORDER BY clauses: ASC becomes DESC and
|
|
151
|
+
# vice versa.
|
|
152
|
+
#
|
|
153
|
+
# @example
|
|
154
|
+
# query.order(:id).reverse_order.to_sql
|
|
155
|
+
# # => "SELECT * FROM `test_table` ORDER BY `test_table`.`id` DESC"
|
|
156
|
+
#
|
|
157
|
+
# @raise [ArgumentError] if any ordering node has no explicit direction
|
|
158
|
+
# @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
|
|
159
|
+
def reverse_order
|
|
160
|
+
clone.tap do |new_instance|
|
|
161
|
+
new_instance.manager.ast.orders = new_instance.manager.ast.orders.map do |order|
|
|
162
|
+
case order
|
|
163
|
+
when Arel::Nodes::Ascending
|
|
164
|
+
Arel::Nodes::Descending.new(order.expr)
|
|
165
|
+
when Arel::Nodes::Descending
|
|
166
|
+
Arel::Nodes::Ascending.new(order.expr)
|
|
167
|
+
else
|
|
168
|
+
raise ArgumentError, "Cannot reverse ordering node without explicit direction: #{order.class.name}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
147
174
|
def group(*columns)
|
|
148
175
|
clone.tap do |new_instance|
|
|
149
176
|
new_instance.manager.group(*columns)
|
|
@@ -182,6 +209,21 @@ module ClickHouse
|
|
|
182
209
|
end
|
|
183
210
|
end
|
|
184
211
|
|
|
212
|
+
# Returns a query that yields no rows.
|
|
213
|
+
#
|
|
214
|
+
# @example
|
|
215
|
+
# query.none.to_sql
|
|
216
|
+
# # => "SELECT * FROM `test_table` WHERE 1 = 0"
|
|
217
|
+
#
|
|
218
|
+
# @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
|
|
219
|
+
def none
|
|
220
|
+
clone.tap do |new_instance|
|
|
221
|
+
new_instance.manager.where(
|
|
222
|
+
Arel.sql('1 = 0')
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
185
227
|
def from(subquery, alias_name)
|
|
186
228
|
clone.tap do |new_instance|
|
|
187
229
|
new_from = if subquery.is_a?(self.class)
|
|
@@ -198,6 +240,71 @@ module ClickHouse
|
|
|
198
240
|
end
|
|
199
241
|
end
|
|
200
242
|
|
|
243
|
+
# Wraps this query as a CTE node with the given name, so it can be passed
|
|
244
|
+
# to #with: `query_builder.with(sub_query.as_cte(:foo))`. The name is
|
|
245
|
+
# rendered as a SQL identifier, not a quoted value.
|
|
246
|
+
#
|
|
247
|
+
# The manager is cloned so later in-place mutations on this builder (e.g.
|
|
248
|
+
# #limit or #offset) do not alter the captured CTE body.
|
|
249
|
+
#
|
|
250
|
+
# @param name [String, Symbol] name the CTE is referenced by
|
|
251
|
+
# @return [Arel::Nodes::Cte]
|
|
252
|
+
def as_cte(name)
|
|
253
|
+
Arel::Nodes::Cte.new(Arel::Nodes::SqlLiteral.new(name.to_s), to_arel.clone)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Attaches a CTE node, emitting `WITH name AS (...)` before the SELECT.
|
|
257
|
+
# The CTE can then be referenced by name in FROM and IN-subquery positions,
|
|
258
|
+
# which is useful when the same subquery is needed in more than one place.
|
|
259
|
+
#
|
|
260
|
+
# Build the node with #as_cte, for example
|
|
261
|
+
# `query_builder.with(sub_query.as_cte(:foo))`.
|
|
262
|
+
#
|
|
263
|
+
# Chained calls accumulate: `query_builder.with(a).with(b)` emits both
|
|
264
|
+
# CTEs. This differs from `Arel::SelectManager#with`, which replaces any
|
|
265
|
+
# previously set CTE and would keep only `b`.
|
|
266
|
+
#
|
|
267
|
+
# @param cte [Arel::Nodes::Cte] a CTE node from #as_cte
|
|
268
|
+
# @return [ClickHouse::Client::QueryBuilder]
|
|
269
|
+
def with(cte)
|
|
270
|
+
unless cte.is_a?(Arel::Nodes::Cte)
|
|
271
|
+
raise ArgumentError, "expected Arel::Nodes::Cte, got #{cte.class}. " \
|
|
272
|
+
'Use #as_cte(name) to create one.'
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
clone.tap do |new_instance|
|
|
276
|
+
existing = new_instance.manager.ast.with
|
|
277
|
+
all_ctes = existing ? existing.children + [cte] : [cte]
|
|
278
|
+
validate_unique_cte_names!(all_ctes)
|
|
279
|
+
new_instance.manager.ast.with = Arel::Nodes::With.new(all_ctes)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Combines this query with one or more other queries using UNION and
|
|
284
|
+
# returns a new builder that selects from the combined result as an
|
|
285
|
+
# aliased subquery, so further chaining (#where, #order, #limit, ...)
|
|
286
|
+
# applies to the outer SELECT.
|
|
287
|
+
#
|
|
288
|
+
# @param others [Array<ClickHouse::Client::QueryBuilder>] queries to combine with
|
|
289
|
+
# @param type [Symbol] :distinct or :all
|
|
290
|
+
# @param alias_name [String, Symbol] alias for the union subquery
|
|
291
|
+
# @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
|
|
292
|
+
def union(*others, type: :distinct, alias_name: 'union_subquery')
|
|
293
|
+
validate_union_type!(type)
|
|
294
|
+
raise ArgumentError, 'at least one query to union with is required' if others.empty?
|
|
295
|
+
|
|
296
|
+
others.each do |other|
|
|
297
|
+
raise ArgumentError, "expected #{self.class}, got #{other.class}" unless other.is_a?(self.class)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
node_class = type == :all ? Arel::Nodes::UnionAll : Arel::Nodes::Union
|
|
301
|
+
union_node = others.reduce(to_arel.clone.ast) do |left, other|
|
|
302
|
+
node_class.new(left, other.to_arel.clone.ast)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
self.class.new(Arel::Nodes::TableAlias.new(union_node, alias_name.to_s))
|
|
306
|
+
end
|
|
307
|
+
|
|
201
308
|
# Adds a JOIN clause. Pass `type: :outer` for `LEFT OUTER JOIN`.
|
|
202
309
|
# To join a subquery, pre-alias it via `QueryBuilder.new(sub, 'x').table`
|
|
203
310
|
# or `sub.to_arel.as('x')` and pass that.
|
|
@@ -383,9 +490,8 @@ module ClickHouse
|
|
|
383
490
|
visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
|
|
384
491
|
end
|
|
385
492
|
|
|
386
|
-
def to_redacted_sql
|
|
387
|
-
visitor = ClickHouse::Client::ToRedactedSqlVisitor.new(ClickHouse::Client::ArelEngine.new
|
|
388
|
-
bind_manager: bind_index_manager)
|
|
493
|
+
def to_redacted_sql
|
|
494
|
+
visitor = ClickHouse::Client::ToRedactedSqlVisitor.new(ClickHouse::Client::ArelEngine.new)
|
|
389
495
|
visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
|
|
390
496
|
end
|
|
391
497
|
|
|
@@ -459,11 +565,26 @@ module ClickHouse
|
|
|
459
565
|
raise ArgumentError, "Invalid order direction '#{direction}'. Must be :asc or :desc"
|
|
460
566
|
end
|
|
461
567
|
|
|
568
|
+
def validate_union_type!(type)
|
|
569
|
+
return if %i[distinct all].include?(type)
|
|
570
|
+
|
|
571
|
+
raise ArgumentError, "Invalid union type '#{type}'. Must be :distinct or :all"
|
|
572
|
+
end
|
|
573
|
+
|
|
462
574
|
def validate_join_type!(type)
|
|
463
575
|
return if %i[inner outer].include?(type)
|
|
464
576
|
|
|
465
577
|
raise ArgumentError, "Invalid join type '#{type}'. Must be :inner or :outer"
|
|
466
578
|
end
|
|
579
|
+
|
|
580
|
+
def validate_unique_cte_names!(ctes)
|
|
581
|
+
names = ctes.map { |cte| cte.name.to_s }
|
|
582
|
+
duplicates = names.tally.select { |_, count| count > 1 }.keys
|
|
583
|
+
|
|
584
|
+
return if duplicates.empty?
|
|
585
|
+
|
|
586
|
+
raise ArgumentError, "duplicate CTE name(s): #{duplicates.join(', ')}"
|
|
587
|
+
end
|
|
467
588
|
end
|
|
468
589
|
end
|
|
469
590
|
end
|
|
@@ -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.12.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-08-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -187,7 +187,6 @@ files:
|
|
|
187
187
|
- lib/click_house/client/arel_engine.rb
|
|
188
188
|
- lib/click_house/client/arel_extensions/nodes/final.rb
|
|
189
189
|
- lib/click_house/client/arel_visitor.rb
|
|
190
|
-
- lib/click_house/client/bind_index_manager.rb
|
|
191
190
|
- lib/click_house/client/configuration.rb
|
|
192
191
|
- lib/click_house/client/database.rb
|
|
193
192
|
- 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
|