click_house-client 0.8.3 → 0.10.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: f12c7192094a0cec94c354381b9be928dff901c03a9e8522dde43a695ce99b62
4
- data.tar.gz: bedf93d6760663e747598c2cf9f60b90106b5d6979a7fe11dd00749cb03e4326
3
+ metadata.gz: bef646c7c07c30559265378e9fec3f14f5e41aeadd86058120a31d532d77d10a
4
+ data.tar.gz: e969763ad335eda82d36f3d949207cfcb95ab238646ff0b3e4ec18e5a17bd445
5
5
  SHA512:
6
- metadata.gz: 5fe06dbc303e5b94e72e84b0da50c96defa1be6445a77236ad29f49ed4283cb0e0d5ce147e5bafba671ac01d7064be5c3f0e4f80ff83ffe74eae7207f1b6a56e
7
- data.tar.gz: a183e8f5081a5f1c4463e0b35c5fde96968023555c6b65ef4e5495a52650f747a224dfb468145cb3c5d5a55333e859398db6284952fec9c323f0bec3a4b6247e
6
+ metadata.gz: 9f7883a21fba0ab5534c85ca43686a3490234252c2277e200a244a3bead56fa3a31d7597c8b23deecb0086b5ad3a90dde67c8425f8ce257c289cb29abb8b29ad
7
+ data.tar.gz: 175aaeaefe0f02f0ba1cd9e3828d9d4bb876c450f626765b78568297c0d35d7e2eb7de00f900ea88db84f7240fbb32790fededbd258dcad4afb4d9291bb16f7c
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ .rspec_status
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- click_house-client (0.8.3)
4
+ click_house-client (0.10.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.8.3)
139
+ click_house-client (0.10.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
@@ -182,9 +182,28 @@ query
182
182
  # => "SELECT * FROM `users` WHERE `users`.`active` = 'true' GROUP BY `users`.`department` HAVING `users`.`avg_salary` > 50000"
183
183
  ```
184
184
 
185
+ ### FINAL Modifier
186
+
187
+ ClickHouse's `FINAL` modifier forces merging of rows during query time for tables in the MergeTree family. Apply it via `.final`:
188
+
189
+ ```ruby
190
+ query.final.to_sql
191
+ # => "SELECT * FROM `users` FINAL"
192
+
193
+ query.final.where(active: true).to_sql
194
+ # => "SELECT * FROM `users` FINAL WHERE `users`.`active` = 'true'"
195
+ ```
196
+
197
+ > ⚠️ **Warning:** Using `FINAL` in production code can cause excessive I/O and affect ClickHouse availability. Prefer using it only in test environments or behind a feature flag.
198
+
199
+ `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
+
185
201
  ### Working with JOINs
186
202
 
187
- When using JOINs, you can apply conditions to joined tables: _(Supports only `INNER JOIN`)_
203
+ `#joins` supports `INNER JOIN` (default) and `LEFT OUTER JOIN` via
204
+ `type: :outer`. The join source can be a table name, an `Arel::Table`, or a
205
+ pre-aliased subquery (`QueryBuilder.new(sub, 'alias').table` or
206
+ `sub.to_arel.as('alias')`).
188
207
 
189
208
  ```ruby
190
209
  # Join with conditions on joined table
@@ -201,6 +220,17 @@ query
201
220
  .having(orders: { total: [100, 200, 300] })
202
221
  .to_sql
203
222
  # => "SELECT * FROM `users` INNER JOIN `orders` ON `users`.`id` = `orders`.`user_id` GROUP BY `users`.`department` HAVING `orders`.`total` IN (100, 200, 300)"
223
+
224
+ # LEFT OUTER JOIN against a pre-aliased subquery
225
+ orders_sub = ClickHouse::Client::QueryBuilder.new(
226
+ ClickHouse::Client::QueryBuilder.new('orders').select(:id, :user_id),
227
+ 'o'
228
+ )
229
+
230
+ query
231
+ .joins(orders_sub.table, { id: :user_id }, type: :outer)
232
+ .to_sql
233
+ # => "SELECT * FROM `users` LEFT OUTER JOIN (SELECT `orders`.`id`, `orders`.`user_id` FROM `orders`) `o` ON `users`.`id` = `o`.`user_id`"
204
234
  ```
205
235
 
206
236
  ### Complete Example
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'arel'
4
+
5
+ module ClickHouse
6
+ module Client
7
+ module ArelExtensions
8
+ module Nodes
9
+ class Final < ::Arel::Nodes::Unary
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -30,6 +30,11 @@ module ClickHouse
30
30
  end
31
31
  end
32
32
 
33
+ def visit_ClickHouse_Client_ArelExtensions_Nodes_Final(object, collector)
34
+ collector = visit(object.expr, collector)
35
+ collector << " FINAL"
36
+ end
37
+
33
38
  # rubocop:enable Naming/MethodName
34
39
  end
35
40
  end
@@ -5,34 +5,46 @@ module ClickHouse
5
5
  class Formatter
6
6
  DEFAULT = ->(value) { value }
7
7
 
8
- BASIC_TYPE_CASTERS = {
9
- 'Int32' => ->(value) { Integer(value) },
10
- 'UInt32' => ->(value) { Integer(value) },
11
- 'Int64' => ->(value) { Integer(value) },
12
- 'UInt64' => ->(value) { Integer(value) },
13
- "DateTime64(6, 'UTC')" => ->(value) { ActiveSupport::TimeZone['UTC'].parse(value) },
8
+ TYPE_CASTERS = {
9
+ # UInt8/16/32/64, Int8/16/32/64
10
+ /\AU?Int\d+\z/ => ->(value) { Integer(value) },
11
+ # Float8/16/32/64
12
+ /\AFloat\d+\z/ => ->(value) { Float(value) },
13
+ # Date, Date32
14
+ /\ADate\d*\z/ => ->(value) { Date.parse(value) },
15
+ # DateTime64(0/3/6/9, 'UTC')
16
+ /\ADateTime64\(\d+, 'UTC'\)\z/ => ->(value) { ActiveSupport::TimeZone['UTC'].parse(value) },
17
+ "DateTime('UTC')" => ->(value) { ActiveSupport::TimeZone['UTC'].parse(value) },
14
18
  "IntervalSecond" => ->(value) { ActiveSupport::Duration.build(value.to_i) },
15
19
  "IntervalMillisecond" => ->(value) { ActiveSupport::Duration.build(value.to_i / 1000.0) }
16
20
  }.freeze
17
21
 
18
- TYPE_CASTERS = BASIC_TYPE_CASTERS.merge(
19
- BASIC_TYPE_CASTERS.transform_keys { |type| "Nullable(#{type})" }
20
- .transform_values { |caster| ->(value) { value.nil? ? nil : caster.call(value) } }
21
- )
22
-
23
22
  def self.format(result)
24
- name_type_mapping = result['meta'].each_with_object({}) do |column, hash|
25
- hash[column['name']] = column['type']
23
+ column_typecasters = result['meta'].each_with_object({}) do |column, hash|
24
+ hash[column['name']] = get_typecaster(column['type']) || DEFAULT
26
25
  end
27
26
 
28
27
  result['data'].map do |row|
29
28
  row.each_with_object({}) do |(column, value), casted_row|
30
- caster = TYPE_CASTERS.fetch(name_type_mapping[column], DEFAULT)
31
-
32
- casted_row[column] = caster.call(value)
29
+ casted_row[column] = value.nil? ? value : column_typecasters[column].call(value)
33
30
  end
34
31
  end
35
32
  end
33
+
34
+ def self.get_typecaster(column_type)
35
+ return unless column_type
36
+
37
+ inner_type = column_type
38
+ .sub(/\ANullable\((.+)\)\z/, '\1') # e.g Nullable(String)
39
+ .sub(/\ALowCardinality\((.+)\)\z/, '\1') # e.g. LowCardinality(String)
40
+ .sub(/\ASimpleAggregateFunction\(.+,\s*(.+)\)\z/, '\1') # e.g. SimpleAggregateFunction(sum, UInt64)
41
+
42
+ TYPE_CASTERS.each do |key, caster|
43
+ return caster if key.is_a?(String) ? key == inner_type : key.match?(inner_type)
44
+ end
45
+
46
+ nil
47
+ end
36
48
  end
37
49
  end
38
50
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_record'
4
+ require_relative 'arel_extensions/nodes/final'
4
5
 
5
6
  module ClickHouse
6
7
  module Client
@@ -29,6 +30,8 @@ module ClickHouse
29
30
  Arel::Nodes::As
30
31
  ].freeze
31
32
 
33
+ delegate :[], to: :table
34
+
32
35
  def initialize(table, alias_name = nil)
33
36
  @table = if table.is_a?(self.class) # subquery
34
37
  Arel::Nodes::TableAlias.new(table.to_arel, alias_name)
@@ -68,6 +71,15 @@ module ClickHouse
68
71
  end
69
72
  end
70
73
 
74
+ # Evaluates given block in scope of current builder. Example:
75
+ # query = ClickHouse::Client::QueryBuilder.new('test_table').build do
76
+ # select(named_func('argMax', [table[:id], table[:timestamp]]).as('max_id')).limit(10)
77
+ # end
78
+ # @return [ClickHouse::QueryBuilder] New instance of query builder.
79
+ def build(&)
80
+ instance_eval(&)
81
+ end
82
+
71
83
  # The `having` method applies constraints to the HAVING clause, similar to how
72
84
  # `where` applies constraints to the WHERE clause. It supports the same constraint types.
73
85
  # Correct usage:
@@ -148,44 +160,96 @@ module ClickHouse
148
160
  self
149
161
  end
150
162
 
163
+ # Applies the ClickHouse `FINAL` modifier to the main FROM table.
164
+ # See https://clickhouse.com/docs/en/sql-reference/statements/select/from#final-modifier
165
+ #
166
+ # @example
167
+ # query.final.to_sql
168
+ # # => "SELECT * FROM `test_table` FINAL"
169
+ #
170
+ # Note: `FINAL` only makes sense on a ClickHouse MergeTree-family table.
171
+ #
172
+ # WARNING: Using `FINAL` in production code can cause excessive I/O and
173
+ # affect ClickHouse availability. Prefer using it only in test environments
174
+ # or behind a feature flag.
175
+ #
176
+ # @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
177
+ def final
178
+ clone.tap do |new_instance|
179
+ source = new_instance.manager.source.left
180
+ wrapped = source.is_a?(ArelExtensions::Nodes::Final) ? source : ArelExtensions::Nodes::Final.new(source)
181
+ new_instance.manager.from(wrapped)
182
+ end
183
+ end
184
+
151
185
  def from(subquery, alias_name)
152
186
  clone.tap do |new_instance|
153
- if subquery.is_a?(self.class)
154
- new_instance.manager.from(subquery.to_arel.as(alias_name))
155
- else
156
- new_instance.manager.from(Arel::Nodes::TableAlias.new(subquery, alias_name))
187
+ new_from = if subquery.is_a?(self.class)
188
+ subquery.to_arel.as(alias_name)
189
+ else
190
+ Arel::Nodes::TableAlias.new(subquery, alias_name)
191
+ end
192
+
193
+ if new_instance.manager.source.left.is_a?(ArelExtensions::Nodes::Final)
194
+ new_from = ArelExtensions::Nodes::Final.new(new_from)
157
195
  end
196
+
197
+ new_instance.manager.from(new_from)
158
198
  end
159
199
  end
160
200
 
161
- def joins(table_name, constraint = nil)
201
+ # Adds a JOIN clause. Pass `type: :outer` for `LEFT OUTER JOIN`.
202
+ # To join a subquery, pre-alias it via `QueryBuilder.new(sub, 'x').table`
203
+ # or `sub.to_arel.as('x')` and pass that.
204
+ # @return [ClickHouse::Client::QueryBuilder] New instance of query builder.
205
+ def joins(source, constraint = nil, type: :inner)
206
+ validate_join_type!(type)
207
+
162
208
  clone.tap do |new_instance|
163
- join_table = table_name.is_a?(Arel::Table) ? table_name : Arel::Table.new(table_name)
209
+ join_target = case source
210
+ when Arel::Table, Arel::Nodes::TableAlias then source
211
+ else Arel::Table.new(source)
212
+ end
213
+ join_class = type == :outer ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin
164
214
 
165
215
  join_condition = case constraint
166
216
  when Hash
167
217
  # Handle hash based constraints like { table1.id: table2.ref_id } or {id: :ref_id}
168
218
  constraint_conditions = constraint.map do |left, right|
169
219
  left_field = left.is_a?(Arel::Attributes::Attribute) ? left : new_instance.table[left]
170
- right_field = right.is_a?(Arel::Attributes::Attribute) ? right : join_table[right]
220
+ right_field = right.is_a?(Arel::Attributes::Attribute) ? right : join_target[right]
171
221
  left_field.eq(right_field)
172
222
  end
173
223
 
174
224
  constraint_conditions.reduce(&:and)
175
225
  when Proc
176
- constraint.call(new_instance.table, join_table)
177
- when Arel::Nodes::Node
226
+ constraint.call(new_instance.table, join_target)
227
+ when Arel::Nodes::Node, Arel::Nodes::SqlLiteral
178
228
  constraint
179
229
  end
180
230
 
181
231
  if join_condition
182
- new_instance.manager.join(join_table).on(join_condition)
232
+ new_instance.manager.join(join_target, join_class).on(join_condition)
183
233
  else
184
- new_instance.manager.join(join_table)
234
+ new_instance.manager.join(join_target, join_class)
185
235
  end
186
236
  end
187
237
  end
188
238
 
239
+ # Shortcut for `Arel::Nodes::NamedFunction.new`
240
+ # @return [Arel::Nodes::NamedFunction]
241
+ def named_func(*args)
242
+ Arel::Nodes::NamedFunction.new(*args)
243
+ end
244
+
245
+ # Shortcut for `Arel::Nodes.build_quoted`
246
+ # @return [Arel::Nodes::Node]
247
+ def quote(...)
248
+ Arel::Nodes.build_quoted(...)
249
+ end
250
+
251
+ alias_method :func, :named_func
252
+
189
253
  # Aggregation helper methods
190
254
 
191
255
  # Creates an AVG aggregate function node
@@ -394,6 +458,12 @@ module ClickHouse
394
458
 
395
459
  raise ArgumentError, "Invalid order direction '#{direction}'. Must be :asc or :desc"
396
460
  end
461
+
462
+ def validate_join_type!(type)
463
+ return if %i[inner outer].include?(type)
464
+
465
+ raise ArgumentError, "Invalid join type '#{type}'. Must be :inner or :outer"
466
+ end
397
467
  end
398
468
  end
399
469
  end
@@ -9,6 +9,7 @@ module ClickHouse
9
9
  when Numeric then value.to_s
10
10
  when String, Symbol then "'#{value.to_s.gsub('\\', '\&\&').gsub("'", "''")}'"
11
11
  when Array then "[#{value.map { |v| quote(v) }.join(',')}]"
12
+ when Time then value.utc.to_f
12
13
  when nil then "NULL"
13
14
  else quote_str(value.to_s)
14
15
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ClickHouse
4
4
  module Client
5
- VERSION = "0.8.3"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
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.8.3
4
+ version: 0.10.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: 2025-11-21 00:00:00.000000000 Z
11
+ date: 2026-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -170,6 +170,7 @@ executables: []
170
170
  extensions: []
171
171
  extra_rdoc_files: []
172
172
  files:
173
+ - ".gitignore"
173
174
  - ".gitlab-ci.yml"
174
175
  - ".rspec"
175
176
  - ".rubocop.yml"
@@ -184,6 +185,7 @@ files:
184
185
  - gemfiles/Gemfile-rails-8.0
185
186
  - lib/click_house/client.rb
186
187
  - lib/click_house/client/arel_engine.rb
188
+ - lib/click_house/client/arel_extensions/nodes/final.rb
187
189
  - lib/click_house/client/arel_visitor.rb
188
190
  - lib/click_house/client/bind_index_manager.rb
189
191
  - lib/click_house/client/configuration.rb