click_house-client 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20e1cebe2da3ec70c0a9ab7be6e5e53d2f2462b4787fd7e7389adfdbe4c64ea9
4
- data.tar.gz: 0bdebe100594f4025f53a5c28723ef776d10ff327283166f70c9990a3323bb62
3
+ metadata.gz: 5513d15d8bf17295cf98d021b30650f46d0101a6d828b2620499f87a949f2e3d
4
+ data.tar.gz: 27fd28cc07d8b67d8de72031de4e3c7e343cef51a3230afd96ba0adf3d24c252
5
5
  SHA512:
6
- metadata.gz: 58e7ed6985feda6a89dd33a1eac938509d3515016eea6c92ea38621c327a94d6e997dd166d67b3b7a2ddd1350edee0e3997055301159c01e8b0e3cac75531707
7
- data.tar.gz: fd25cb3026f5be1d693181cbab8c13915abdad23ac0acd31e4d098b4477c35d6ef99e010e3e4c1477eb00b729bbdaecc5d4a2b40f36d1a35d147f4cdd6f1402f
6
+ metadata.gz: b3d7bf7328dd7ab42b004e3e35e58c35b4869267e4a2e701f7d29f963c2c445c3d63f0bbd5e45db679cc4d2fda6880ef367aff18e0a04b1a030f54ede2317ead
7
+ data.tar.gz: 40125fc9d75f13be2f1230ea872b72c6d01e797384674064d3fb42c613d1792c827e58d6ccfcd894b783ff4d89b838c04e9f50b2e297f4b085bba5c33b95585e
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`:
@@ -260,6 +293,30 @@ query_builder.with(inner.as_cte(:a)).with(inner.as_cte(:b)).to_sql
260
293
  (build it via `#as_cte`), and raises if the same CTE name is declared more than
261
294
  once.
262
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
+
263
320
  ### Complete Example
264
321
 
265
322
  Here's a comprehensive example combining multiple QueryBuilder features:
@@ -287,12 +344,12 @@ result = ClickHouse::Client::QueryBuilder.new('users')
287
344
  .limit(10)
288
345
 
289
346
  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
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
296
353
  ORDER BY user_count DESC LIMIT 10"
297
354
  ```
298
355
 
@@ -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']] = get_typecaster(column['type']) || DEFAULT
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.get_typecaster(column_type)
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
- TYPE_CASTERS.each do |key, caster|
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(bind_index_manager = BindIndexManager.new)
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(bind_index_manager)
59
+ value.to_redacted_sql
60
60
  else
61
- bind_index_manager.next_bind_str
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 = if table.is_a?(self.class) # subquery
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)
@@ -238,6 +280,31 @@ module ClickHouse
238
280
  end
239
281
  end
240
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
+
241
308
  # Adds a JOIN clause. Pass `type: :outer` for `LEFT OUTER JOIN`.
242
309
  # To join a subquery, pre-alias it via `QueryBuilder.new(sub, 'x').table`
243
310
  # or `sub.to_arel.as('x')` and pass that.
@@ -423,9 +490,8 @@ module ClickHouse
423
490
  visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
424
491
  end
425
492
 
426
- def to_redacted_sql(bind_index_manager = ClickHouse::Client::BindIndexManager.new)
427
- visitor = ClickHouse::Client::ToRedactedSqlVisitor.new(ClickHouse::Client::ArelEngine.new,
428
- bind_manager: bind_index_manager)
493
+ def to_redacted_sql
494
+ visitor = ClickHouse::Client::ToRedactedSqlVisitor.new(ClickHouse::Client::ArelEngine.new)
429
495
  visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
430
496
  end
431
497
 
@@ -499,6 +565,12 @@ module ClickHouse
499
565
  raise ArgumentError, "Invalid order direction '#{direction}'. Must be :asc or :desc"
500
566
  end
501
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
+
502
574
  def validate_join_type!(type)
503
575
  return if %i[inner outer].include?(type)
504
576
 
@@ -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(bind_index_manager = BindIndexManager.new)
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\" = $1"
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] = Arel.sql(bind_manager.next_bind_str)
76
+ redacted_o.expressions[1] = bind_param
84
77
  else
85
- redacted_o.expressions = redacted_o.expressions.map do
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) do
104
- Arel.sql(bind_manager.next_bind_str)
105
- end
94
+ Array.new(o.right.size) { bind_param }
106
95
  else
107
- Arel.sql(bind_manager.next_bind_str)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ClickHouse
4
4
  module Client
5
- VERSION = "0.11.0"
5
+ VERSION = "0.12.0"
6
6
  end
7
7
  end
@@ -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.11.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-06-19 00:00:00.000000000 Z
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