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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20e1cebe2da3ec70c0a9ab7be6e5e53d2f2462b4787fd7e7389adfdbe4c64ea9
4
- data.tar.gz: 0bdebe100594f4025f53a5c28723ef776d10ff327283166f70c9990a3323bb62
3
+ metadata.gz: 839c38cadbcf3638fc513c949c9f7d1b40453c92c0274e32db1d1e9da86df156
4
+ data.tar.gz: f23d52098f04a9cdb460bf0d8479b19947641546b37ccd85519eeeaba3e59eb6
5
5
  SHA512:
6
- metadata.gz: 58e7ed6985feda6a89dd33a1eac938509d3515016eea6c92ea38621c327a94d6e997dd166d67b3b7a2ddd1350edee0e3997055301159c01e8b0e3cac75531707
7
- data.tar.gz: fd25cb3026f5be1d693181cbab8c13915abdad23ac0acd31e4d098b4477c35d6ef99e010e3e4c1477eb00b729bbdaecc5d4a2b40f36d1a35d147f4cdd6f1402f
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.11.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.11.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']] = 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
@@ -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 = if table.is_a?(self.class) # subquery
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(bind_index_manager = ClickHouse::Client::BindIndexManager.new)
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(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.13.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.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-06-19 00:00:00.000000000 Z
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