click_house-client 0.7.0 → 0.8.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 +178 -0
- data/lib/click_house/client/query_builder.rb +143 -1
- data/lib/click_house/client/version.rb +1 -1
- data/lib/click_house/client.rb +0 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 45b65dc2ae8ced88a1129e3fb7e78e3d314bbc5eb41402e430932d7e4c169e98
|
4
|
+
data.tar.gz: 9f25ca4f27d5c0b500339bc682d6de3b5b0ffd97b5484b14707949906064b891
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a50a5010fb3900ec46fc9c84fe4bbe6f3647beee599fd40940ddfa89f7ae6ba6a0eb569021b9f34a0e9f72237277b75b34a4820523fa2413a95b696d16c9a2c5
|
7
|
+
data.tar.gz: 998b97239232786839a7568a8a06aa69090127ae1ba68be5cb30bfb57ad26c599aedfda37a4603b0cdf3464a7dd9209f88793688ed5af81dcd0f4315646ed97c
|
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.8.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.8.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
@@ -61,6 +61,184 @@ 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
|
+
## ClickHouse::Client::QueryBuilder
|
65
|
+
|
66
|
+
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.
|
67
|
+
|
68
|
+
### Basic Usage
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
# Initialize a query builder for a table
|
72
|
+
query = ClickHouse::Client::QueryBuilder.new('users')
|
73
|
+
|
74
|
+
# Build and execute queries
|
75
|
+
query.select(:id, :name).where(active: true).to_sql
|
76
|
+
# => "SELECT `users`.`id`, `users`.`name` FROM `users` WHERE `users`.`active` = 'true'"
|
77
|
+
```
|
78
|
+
|
79
|
+
### WHERE Clause
|
80
|
+
|
81
|
+
The `where` method supports various types of conditions:
|
82
|
+
|
83
|
+
#### Simple Equality Conditions
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
query.where(status: 'active').to_sql
|
87
|
+
# => "SELECT * FROM `users` WHERE `users`.`status` = 'active'"
|
88
|
+
|
89
|
+
# Multiple conditions (joined with AND)
|
90
|
+
query.where(status: 'active', role: 'admin').to_sql
|
91
|
+
# => "SELECT * FROM `users` WHERE `users`.`status` = 'active' AND `users`.`role` = 'admin'"
|
92
|
+
```
|
93
|
+
|
94
|
+
#### Array Conditions (IN clause)
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
query.where(id: [1, 2, 3]).to_sql
|
98
|
+
# => "SELECT * FROM `users` WHERE `users`.`id` IN (1, 2, 3)"
|
99
|
+
```
|
100
|
+
|
101
|
+
#### Using Arel Nodes for Complex Conditions
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
# Greater than
|
105
|
+
query.where(query.table[:age].gt(18)).to_sql
|
106
|
+
# => "SELECT * FROM `users` WHERE `users`.`age` > 18"
|
107
|
+
|
108
|
+
# Less than
|
109
|
+
query.where(query.table[:price].lt(100)).to_sql
|
110
|
+
# => "SELECT * FROM `users` WHERE `users`.`price` < 100"
|
111
|
+
|
112
|
+
# Between
|
113
|
+
query.where(query.table[:created_at].between(Date.yesterday..Date.today)).to_sql
|
114
|
+
# => "SELECT * FROM `users` WHERE `users`.`created_at` BETWEEN '2025-09-10' AND '2025-09-11'"
|
115
|
+
|
116
|
+
# Combining conditions with AND
|
117
|
+
condition = query.table[:age].gt(18).and(query.table[:status].eq('active'))
|
118
|
+
query.where(condition).to_sql
|
119
|
+
# => "SELECT * FROM `users` WHERE `users`.`age` > 18 AND `users`.`status` = 'active'"
|
120
|
+
|
121
|
+
# Combining conditions with OR
|
122
|
+
condition = query.table[:role].eq('admin').or(query.table[:role].eq('moderator'))
|
123
|
+
query.where(condition).to_sql
|
124
|
+
# => "SELECT * FROM `users` WHERE (`users`.`role` = 'admin' OR `users`.`role` = 'moderator')"
|
125
|
+
|
126
|
+
# List of supported node types in where clause
|
127
|
+
puts ClickHouse::Client::QueryBuilder::VALID_NODES
|
128
|
+
```
|
129
|
+
|
130
|
+
#### Pattern Matching with LIKE/ILIKE
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
# Case-insensitive pattern matching (ILIKE - default)
|
134
|
+
query.where(query.table[:email].matches('%@example.com')).to_sql
|
135
|
+
# => "SELECT * FROM `users` WHERE `users`.`email` ILIKE '%@example.com'"
|
136
|
+
|
137
|
+
# Case-sensitive pattern matching (LIKE)
|
138
|
+
query.where(query.table[:name].matches('John%', nil, true)).to_sql
|
139
|
+
# => "SELECT * FROM `users` WHERE `users`.`name` LIKE 'John%'"
|
140
|
+
|
141
|
+
# Negative pattern matching (NOT ILIKE)
|
142
|
+
query.where(query.table[:email].does_not_match('%@spam.com')).to_sql
|
143
|
+
# => "SELECT * FROM `users` WHERE `users`.`email` NOT ILIKE '%@spam.com'"
|
144
|
+
```
|
145
|
+
|
146
|
+
#### Subqueries
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
# Using a subquery in WHERE clause
|
150
|
+
subquery = ClickHouse::Client::QueryBuilder.new('orders')
|
151
|
+
.select(:user_id)
|
152
|
+
.where(status: 'completed')
|
153
|
+
|
154
|
+
query.where(id: subquery).to_sql
|
155
|
+
# => "SELECT * FROM `users` WHERE `users`.`id` IN (SELECT `orders`.`user_id` FROM `orders` WHERE `orders`.`status` = 'completed')"
|
156
|
+
```
|
157
|
+
|
158
|
+
### HAVING Clause
|
159
|
+
|
160
|
+
The `having` method works similarly to `where` but is used for filtering aggregated results:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
# Using COUNT(*) in HAVING clause
|
164
|
+
count_func = Arel::Nodes::NamedFunction.new('COUNT', [Arel.star])
|
165
|
+
query.group(:department).having(count_func.gt(10)).to_sql
|
166
|
+
# => "SELECT * FROM `users` GROUP BY `users`.`department` HAVING COUNT(*) > 10"
|
167
|
+
|
168
|
+
# Using other aggregation functions
|
169
|
+
sum_func = Arel::Nodes::NamedFunction.new('SUM', [query.table[:salary]])
|
170
|
+
query.group(:department).having(sum_func.gt(100000)).to_sql
|
171
|
+
# => "SELECT * FROM `users` GROUP BY `users`.`department` HAVING SUM(`users`.`salary`) > 100000"
|
172
|
+
```
|
173
|
+
|
174
|
+
#### Combining WHERE and HAVING
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
query
|
178
|
+
.where(active: true)
|
179
|
+
.group(:department)
|
180
|
+
.having(query.table[:avg_salary].gt(50000))
|
181
|
+
.to_sql
|
182
|
+
# => "SELECT * FROM `users` WHERE `users`.`active` = 'true' GROUP BY `users`.`department` HAVING `users`.`avg_salary` > 50000"
|
183
|
+
```
|
184
|
+
|
185
|
+
### Working with JOINs
|
186
|
+
|
187
|
+
When using JOINs, you can apply conditions to joined tables: _(Supports only `INNER JOIN`)_
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
# Join with conditions on joined table
|
191
|
+
query
|
192
|
+
.joins('orders', { 'id' => 'user_id' })
|
193
|
+
.where(orders: { status: 'pending' })
|
194
|
+
.to_sql
|
195
|
+
# => "SELECT * FROM `users` INNER JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`status` = 'pending'"
|
196
|
+
|
197
|
+
# HAVING clause with joined tables
|
198
|
+
query
|
199
|
+
.joins('orders', { 'id' => 'user_id' })
|
200
|
+
.group(:department)
|
201
|
+
.having(orders: { total: [100, 200, 300] })
|
202
|
+
.to_sql
|
203
|
+
# => "SELECT * FROM `users` INNER JOIN `orders` ON `users`.`id` = `orders`.`user_id` GROUP BY `users`.`department` HAVING `orders`.`total` IN (100, 200, 300)"
|
204
|
+
```
|
205
|
+
|
206
|
+
### Complete Example
|
207
|
+
|
208
|
+
Here's a comprehensive example combining multiple QueryBuilder features:
|
209
|
+
|
210
|
+
```ruby
|
211
|
+
# Find active users in specific departments who have completed orders
|
212
|
+
# Group by department and filter groups with more than 5 users
|
213
|
+
|
214
|
+
completed_orders = ClickHouse::Client::QueryBuilder.new('orders')
|
215
|
+
.select(:user_id)
|
216
|
+
.where(status: 'completed')
|
217
|
+
.where(query.table[:created_at].gt(Date.today - 30))
|
218
|
+
|
219
|
+
count_func = Arel::Nodes::NamedFunction.new('COUNT', [Arel.star])
|
220
|
+
|
221
|
+
result = ClickHouse::Client::QueryBuilder.new('users')
|
222
|
+
.select(:department, count_func.as('user_count'))
|
223
|
+
.where(active: true)
|
224
|
+
.where(department: ['Sales', 'Marketing', 'Engineering'])
|
225
|
+
.where(id: completed_orders)
|
226
|
+
.where(query.table[:email].matches('%@company.com'))
|
227
|
+
.group(:department)
|
228
|
+
.having(count_func.gt(5))
|
229
|
+
.order(Arel.sql('user_count'), :desc)
|
230
|
+
.limit(10)
|
231
|
+
|
232
|
+
puts result.to_sql
|
233
|
+
"SELECT `users`.`department`, COUNT(*) AS user_count FROM `users` WHERE `users`.`active` = 'true'
|
234
|
+
AND `users`.`department` IN ('Sales', 'Marketing', 'Engineering')
|
235
|
+
AND `users`.`id` IN (SELECT `orders`.`user_id` FROM `orders` WHERE `orders`.`status` = 'completed'
|
236
|
+
AND `users`.`created_at` > '2025-08-12')
|
237
|
+
AND `users`.`email` ILIKE '%@company.com'
|
238
|
+
GROUP BY department HAVING COUNT(*) AS user_count > 5
|
239
|
+
ORDER BY user_count DESC LIMIT 10"
|
240
|
+
```
|
241
|
+
|
64
242
|
## License
|
65
243
|
|
66
244
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -23,7 +23,10 @@ module ClickHouse
|
|
23
23
|
Arel::Nodes::Or,
|
24
24
|
Arel::Nodes::Grouping,
|
25
25
|
Arel::Nodes::Matches,
|
26
|
-
Arel::Nodes::DoesNotMatch
|
26
|
+
Arel::Nodes::DoesNotMatch,
|
27
|
+
Arel::Nodes::Division,
|
28
|
+
Arel::Nodes::Multiplication,
|
29
|
+
Arel::Nodes::As
|
27
30
|
].freeze
|
28
31
|
|
29
32
|
def initialize(table_name)
|
@@ -178,6 +181,134 @@ module ClickHouse
|
|
178
181
|
end
|
179
182
|
end
|
180
183
|
|
184
|
+
# Aggregation helper methods
|
185
|
+
|
186
|
+
# Creates an AVG aggregate function node
|
187
|
+
# @param column [Symbol, String, Arel::Expressions] The column to average
|
188
|
+
# @return [Arel::Nodes::NamedFunction] The AVG function node
|
189
|
+
# @example Basic average
|
190
|
+
# query.select(query.avg(:duration)).to_sql
|
191
|
+
# # => "SELECT avg(`table`.`duration`) FROM `table`"
|
192
|
+
# @example Average with alias
|
193
|
+
# query.select(query.avg(:price).as('average_price')).to_sql
|
194
|
+
# # => "SELECT avg(`table`.`price`) AS average_price FROM `table`"
|
195
|
+
def avg(column)
|
196
|
+
column_node = normalize_operand(column)
|
197
|
+
Arel::Nodes::NamedFunction.new('avg', [column_node])
|
198
|
+
end
|
199
|
+
|
200
|
+
# Creates a quantile aggregate function node
|
201
|
+
# @param level [Float] The quantile level (e.g., 0.5 for median)
|
202
|
+
# @param column [Symbol, String, Arel::Expressions] The column to calculate quantile for
|
203
|
+
# @return [Arel::Nodes::NamedFunction] The quantile function node
|
204
|
+
# @example Calculate median (50th percentile)
|
205
|
+
# query.select(query.quantile(0.5, :response_time)).to_sql
|
206
|
+
# # => "SELECT quantile(0.5)(`table`.`response_time`) FROM `table`"
|
207
|
+
# @example Calculate 95th percentile with alias
|
208
|
+
# query.select(query.quantile(0.95, :latency).as('p95')).to_sql
|
209
|
+
# # => "SELECT quantile(0.95)(`table`.`latency`) AS p95 FROM `table`"
|
210
|
+
def quantile(level, column)
|
211
|
+
column_node = normalize_operand(column)
|
212
|
+
Arel::Nodes::NamedFunction.new("quantile(#{level})", [column_node])
|
213
|
+
end
|
214
|
+
|
215
|
+
# Creates a COUNT aggregate function node
|
216
|
+
# @param column [Symbol, String, Arel::Expressions, nil] The column to count, or nil for COUNT(*)
|
217
|
+
# @return [Arel::Nodes::NamedFunction] The COUNT function node
|
218
|
+
# @example Count all rows
|
219
|
+
# query.select(query.count).to_sql
|
220
|
+
# # => "SELECT count() FROM `table`"
|
221
|
+
# @example Count specific column
|
222
|
+
# query.select(query.count(:id)).to_sql
|
223
|
+
# # => "SELECT count(`table`.`id`) FROM `table`"
|
224
|
+
def count(column = nil)
|
225
|
+
if column.nil?
|
226
|
+
Arel::Nodes::NamedFunction.new('count', [])
|
227
|
+
else
|
228
|
+
column_node = normalize_operand(column)
|
229
|
+
Arel::Nodes::NamedFunction.new('count', [column_node])
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Creates a countIf aggregate function node
|
234
|
+
# @param condition [Arel::Nodes::Node] The condition to count
|
235
|
+
# @return [Arel::Nodes::NamedFunction] The countIf function node
|
236
|
+
# @raise [ArgumentError] if condition is not an Arel node
|
237
|
+
# @example Count rows matching a condition
|
238
|
+
# query.select(query.count_if(query.table[:status].eq('active'))).to_sql
|
239
|
+
# # => "SELECT countIf(`table`.`status` = 'active') FROM `table`"
|
240
|
+
def count_if(condition)
|
241
|
+
raise ArgumentError, "countIf requires an Arel node as condition" unless condition.is_a?(Arel::Nodes::Node)
|
242
|
+
|
243
|
+
Arel::Nodes::NamedFunction.new('countIf', [condition])
|
244
|
+
end
|
245
|
+
|
246
|
+
# Creates a division node with grouping
|
247
|
+
# @param left [Arel::Expressions, Symbol, String, Numeric] The dividend
|
248
|
+
# @param right [Arel::Expressions, Symbol, String, Numeric] The divisor
|
249
|
+
# @return [Arel::Nodes::Grouping] The grouped division node for proper precedence
|
250
|
+
# @example Simple division
|
251
|
+
# query.select(query.division(:completed, :total)).to_sql
|
252
|
+
# # => "SELECT (`table`.`completed` / `table`.`total`) FROM `table`"
|
253
|
+
# @example Calculate percentage
|
254
|
+
# rate = query.division(:success_count, :total_count)
|
255
|
+
# query.select(query.multiply(rate, 100).as('success_rate')).to_sql
|
256
|
+
# # => "SELECT ((`table`.`success_count` / `table`.`total_count`) * 100) AS success_rate FROM `table`"
|
257
|
+
def division(left, right)
|
258
|
+
left_node = normalize_operand(left)
|
259
|
+
right_node = normalize_operand(right)
|
260
|
+
|
261
|
+
Arel::Nodes::Grouping.new(Arel::Nodes::Division.new(left_node, right_node))
|
262
|
+
end
|
263
|
+
|
264
|
+
# Creates a multiplication node with grouping
|
265
|
+
# @param left [Arel::Expressions, Symbol, String, Numeric] The left operand
|
266
|
+
# @param right [Arel::Expressions, Symbol, String, Numeric] The right operand
|
267
|
+
# @return [Arel::Nodes::Grouping] The grouped multiplication node for proper precedence
|
268
|
+
# @example Multiply columns
|
269
|
+
# query.select(query.multiply(:quantity, :unit_price)).to_sql
|
270
|
+
# # => "SELECT (`table`.`quantity` * `table`.`unit_price`) FROM `table`"
|
271
|
+
# @example Convert to percentage
|
272
|
+
# query.select(query.multiply(:rate, 100).as('percentage')).to_sql
|
273
|
+
# # => "SELECT (`table`.`rate` * 100) AS percentage FROM `table`"
|
274
|
+
def multiply(left, right)
|
275
|
+
left_node = normalize_operand(left)
|
276
|
+
right_node = normalize_operand(right)
|
277
|
+
|
278
|
+
Arel::Nodes::Grouping.new(Arel::Nodes::Multiplication.new(left_node, right_node))
|
279
|
+
end
|
280
|
+
|
281
|
+
# Creates an equality node
|
282
|
+
# @param left [Arel::Expressions, Symbol, String] The left side of the comparison
|
283
|
+
# @param right [Arel::Expressions, Symbol, String, Numeric, Boolean] The right side of the comparison
|
284
|
+
# @return [Arel::Nodes::Equality] The equality node
|
285
|
+
# @example Use in WHERE clause
|
286
|
+
# query.where(query.equality(:status, 'active')).to_sql
|
287
|
+
# # => "SELECT * FROM `table` WHERE `table`.`status` = 'active'"
|
288
|
+
# @example Use with countIf
|
289
|
+
# query.select(query.count_if(query.equality(:type, 'premium'))).to_sql
|
290
|
+
# # => "SELECT countIf(`table`.`type` = 'premium') FROM `table`"
|
291
|
+
def equality(left, right)
|
292
|
+
left_node = normalize_operand(left)
|
293
|
+
right_node = normalize_operand(right)
|
294
|
+
Arel::Nodes::Equality.new(left_node, right_node)
|
295
|
+
end
|
296
|
+
|
297
|
+
# Creates an alias for a node
|
298
|
+
# @param node [Arel::Nodes::Node] The node to alias
|
299
|
+
# @param alias_name [String, Symbol] The alias name
|
300
|
+
# @return [Arel::Nodes::As] The aliased node
|
301
|
+
# @raise [ArgumentError] if node is not an Arel Expression
|
302
|
+
# @example Alias an aggregate function
|
303
|
+
# avg_node = query.avg(:price)
|
304
|
+
# query.select(query.as(avg_node, 'average_price')).to_sql
|
305
|
+
# # => "SELECT avg(`table`.`price`) AS average_price FROM `table`"
|
306
|
+
def as(node, alias_name)
|
307
|
+
raise ArgumentError, "as requires an Arel node" unless node.is_a?(Arel::Expressions)
|
308
|
+
|
309
|
+
node.as(alias_name.to_s)
|
310
|
+
end
|
311
|
+
|
181
312
|
def to_sql
|
182
313
|
visitor = ClickHouse::Client::ArelVisitor.new(ClickHouse::Client::ArelEngine.new)
|
183
314
|
visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
|
@@ -193,6 +324,17 @@ module ClickHouse
|
|
193
324
|
|
194
325
|
private
|
195
326
|
|
327
|
+
def normalize_operand(operand)
|
328
|
+
case operand
|
329
|
+
when Arel::Expressions
|
330
|
+
operand
|
331
|
+
when Symbol, String
|
332
|
+
table[operand.to_s]
|
333
|
+
else
|
334
|
+
Arel::Nodes.build_quoted(operand)
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
196
338
|
def validate_constraint_type!(constraint)
|
197
339
|
return unless constraint.is_a?(Arel::Nodes::Node) && VALID_NODES.exclude?(constraint.class)
|
198
340
|
|
data/lib/click_house/client.rb
CHANGED
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.8.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-09-
|
11
|
+
date: 2025-09-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|