ch_connect 0.2.2 → 0.3.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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ch_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karol Bąk
@@ -24,41 +24,46 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '3.1'
26
26
  - !ruby/object:Gem::Dependency
27
- name: httpx
27
+ name: connection_pool
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '1.0'
32
+ version: '3.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '1.0'
40
- description: Fast Ruby client for ClickHouse database using the Native binary format
41
- for efficient data transfer
39
+ version: '3.0'
40
+ description: Fast Ruby client for ClickHouse using its native TCP protocol and binary
41
+ format
42
42
  email:
43
43
  - kukicola@gmail.com
44
44
  executables: []
45
- extensions: []
45
+ extensions:
46
+ - ext/ch_connect_native/extconf.rb
46
47
  extra_rdoc_files: []
47
48
  files:
48
49
  - ".rspec"
49
50
  - ".standard.yml"
50
51
  - CHANGELOG.md
51
52
  - README.md
53
+ - ext/ch_connect_native/ch_connect_native.c
54
+ - ext/ch_connect_native/extconf.rb
52
55
  - lib/ch_connect.rb
53
- - lib/ch_connect/body_reader.rb
54
56
  - lib/ch_connect/config.rb
55
57
  - lib/ch_connect/connection.rb
56
- - lib/ch_connect/http_transport.rb
57
- - lib/ch_connect/native_format_parser.rb
58
58
  - lib/ch_connect/null_instrumenter.rb
59
59
  - lib/ch_connect/response.rb
60
- - lib/ch_connect/transport_result.rb
61
60
  - lib/ch_connect/version.rb
61
+ - vendor/clickhouse-c/LICENSE
62
+ - vendor/clickhouse-c/VENDOR.md
63
+ - vendor/clickhouse-c/clickhouse-async.h
64
+ - vendor/clickhouse-c/clickhouse-client.h
65
+ - vendor/clickhouse-c/clickhouse-compression.h
66
+ - vendor/clickhouse-c/clickhouse.h
62
67
  homepage: https://github.com/kukicola/ch_connect
63
68
  licenses:
64
69
  - MIT
@@ -71,14 +76,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
71
76
  requirements:
72
77
  - - ">="
73
78
  - !ruby/object:Gem::Version
74
- version: 3.1.0
79
+ version: 3.2.0
75
80
  required_rubygems_version: !ruby/object:Gem::Requirement
76
81
  requirements:
77
82
  - - ">="
78
83
  - !ruby/object:Gem::Version
79
84
  version: '0'
80
85
  requirements: []
81
- rubygems_version: 3.6.7
86
+ rubygems_version: 4.0.6
82
87
  specification_version: 4
83
- summary: Ruby client for ClickHouse with Native format support
88
+ summary: Ruby client for ClickHouse's native TCP protocol
84
89
  test_files: []
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ChConnect
4
- # Wrapper for HTTP response body providing buffered reads.
5
- # Reads data in chunks for efficient small reads.
6
- # @api private
7
- class BodyReader
8
- CHUNK_SIZE = 64 * 1024 # 64KB chunks
9
-
10
- # Creates a new body reader.
11
- #
12
- # @param body [#read, #bytesize, #close] HTTP response body
13
- def initialize(body)
14
- @body = body
15
- @size = body.bytesize
16
- @buffer = "".b
17
- @buffer_pos = 0
18
- @eof = false
19
- end
20
-
21
- # Closes the underlying body.
22
- #
23
- # @return [void]
24
- def close
25
- @body.close
26
- end
27
-
28
- # Returns true if at end of stream.
29
- #
30
- # @return [Boolean]
31
- def eof?
32
- fill_buffer(1) if @buffer_pos >= @buffer.bytesize && !@eof
33
- @eof && @buffer_pos >= @buffer.bytesize
34
- end
35
-
36
- # Reads exactly n bytes from the body.
37
- #
38
- # @param n [Integer] number of bytes to read
39
- # @return [String] binary string of n bytes
40
- def read(n)
41
- fill_buffer(n)
42
- result = @buffer.byteslice(@buffer_pos, n)
43
- @buffer_pos += n
44
- compact_buffer if @buffer_pos > CHUNK_SIZE
45
- result
46
- end
47
-
48
- # Reads a single byte as integer, returns nil at EOF.
49
- #
50
- # @return [Integer, nil] byte value or nil at EOF
51
- def getbyte
52
- fill_buffer(1)
53
- return nil if @buffer_pos >= @buffer.bytesize
54
-
55
- byte = @buffer.getbyte(@buffer_pos)
56
- @buffer_pos += 1
57
- compact_buffer if @buffer_pos > CHUNK_SIZE
58
- byte
59
- end
60
-
61
- private
62
-
63
- def fill_buffer(needed)
64
- while !@eof && (@buffer.bytesize - @buffer_pos) < needed
65
- chunk = @body.read(CHUNK_SIZE)
66
- if chunk.nil? || chunk.empty?
67
- @eof = true
68
- else
69
- @buffer << chunk
70
- end
71
- end
72
- end
73
-
74
- def compact_buffer
75
- @buffer = @buffer.byteslice(@buffer_pos..-1) || "".b
76
- @buffer_pos = 0
77
- end
78
- end
79
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "httpx"
4
- require "json"
5
-
6
- module ChConnect
7
- # HTTP transport layer for ClickHouse communication.
8
- # @api private
9
- class HttpTransport
10
- # Creates a new HTTP transport.
11
- #
12
- # @param config [Config] configuration instance
13
- def initialize(config)
14
- @config = config
15
- @base_url = "#{config.scheme}://#{config.host}:#{config.port}"
16
- @http_client = HTTPX.plugin(:persistent, close_on_fork: true)
17
- .plugin(:retries, max_retries: config.max_retries, retry_change_requests: true)
18
- .with(
19
- timeout: {
20
- connect_timeout: config.connection_timeout,
21
- read_timeout: config.read_timeout,
22
- write_timeout: config.write_timeout,
23
- keep_alive_timeout: config.keep_alive_timeout
24
- },
25
- pool_options: {
26
- max_connections_per_origin: config.pool_size,
27
- pool_timeout: config.pool_timeout
28
- }
29
- )
30
-
31
- @default_headers = {
32
- "Accept-Encoding" => "gzip",
33
- "X-ClickHouse-User" => config.username,
34
- "X-ClickHouse-Key" => config.password,
35
- "X-ClickHouse-Format" => "Native"
36
- }
37
- end
38
-
39
- # Executes a SQL query via HTTP.
40
- #
41
- # @param sql [String] SQL query to execute
42
- # @param options [Hash] query options
43
- # @option options [Hash] :params query parameters
44
- # @return [TransportResult] result containing body and summary
45
- # @raise [QueryError] if the query fails
46
- def execute(sql, options = {})
47
- query_params = {database: @config.database}.merge(options[:params] || {})
48
- response = @http_client.post(@base_url, params: query_params, body: sql, headers: @default_headers)
49
-
50
- raise QueryError, response.error.message if response.error
51
-
52
- summary = JSON.parse(response.headers["x-clickhouse-summary"], symbolize_names: true)
53
-
54
- raise QueryError, response.body.to_s unless response.status == 200
55
-
56
- TransportResult.new(body: response.body, summary: summary)
57
- end
58
- end
59
- end
@@ -1,405 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
- require "ipaddr"
5
-
6
- module ChConnect
7
- # Parser for ClickHouse Native binary format.
8
- # @api private
9
- class NativeFormatParser
10
- DATE_EPOCH = Date.new(1970, 1, 1)
11
-
12
- # Creates a new parser.
13
- #
14
- # @param body [#read] response body to parse
15
- def initialize(body)
16
- @reader = BodyReader.new(body)
17
- @columns = []
18
- @types = []
19
- @rows = []
20
- end
21
-
22
- # Parses the response body and returns a Response.
23
- #
24
- # @return [Response] parsed response with columns, types, and rows
25
- # @raise [UnsupportedTypeError] if an unsupported data type is encountered
26
- def parse
27
- parse_block until @reader.eof?
28
- Response.new(columns: @columns, types: @types, rows: @rows)
29
- ensure
30
- @reader.close
31
- end
32
-
33
- private
34
-
35
- def parse_block
36
- num_columns = read_varint
37
- num_rows = read_varint
38
-
39
- return if num_columns == 0 && num_rows == 0
40
-
41
- columns_data = []
42
-
43
- num_columns.times do
44
- col_name = read_string
45
- col_type = read_string
46
-
47
- if @columns.length < num_columns
48
- @columns << col_name.to_sym
49
- @types << col_type.to_sym
50
- end
51
-
52
- columns_data << read_column(col_type, num_rows)
53
- end
54
-
55
- @rows.concat(columns_data.transpose) if num_rows > 0
56
- end
57
-
58
- def read_column(type, num_rows)
59
- case type
60
- # Integers
61
- when "UInt8" then read_uint8_column(num_rows)
62
- when "UInt16" then read_uint16_column(num_rows)
63
- when "UInt32" then read_uint32_column(num_rows)
64
- when "UInt64" then read_uint64_column(num_rows)
65
- when "UInt128" then read_uint128_column(num_rows)
66
- when "UInt256" then read_uint256_column(num_rows)
67
- when "Int8" then read_int8_column(num_rows)
68
- when "Int16" then read_int16_column(num_rows)
69
- when "Int32" then read_int32_column(num_rows)
70
- when "Int64" then read_int64_column(num_rows)
71
- when "Int128" then read_int128_column(num_rows)
72
- when "Int256" then read_int256_column(num_rows)
73
-
74
- # Floats
75
- when "Float32" then read_float32_column(num_rows)
76
- when "Float64" then read_float64_column(num_rows)
77
-
78
- # Boolean
79
- when "Bool" then read_bool_column(num_rows)
80
-
81
- # Strings
82
- when "String" then read_string_column(num_rows)
83
- when /^FixedString\((\d+)\)$/ then read_fixed_string_column($1.to_i, num_rows)
84
-
85
- # Dates and Times
86
- when "Date" then read_date_column(num_rows)
87
- when "Date32" then read_date32_column(num_rows)
88
- when "DateTime", /^DateTime\(.+\)$/ then read_datetime_column(num_rows)
89
- when /^DateTime64\((\d+)(?:,.*)?\)$/ then read_datetime64_column($1.to_i, num_rows)
90
-
91
- # UUID
92
- when "UUID" then read_uuid_column(num_rows)
93
-
94
- # IP addresses
95
- when "IPv4" then read_ipv4_column(num_rows)
96
- when "IPv6" then read_ipv6_column(num_rows)
97
-
98
- # Decimals - ClickHouse always returns Decimal(precision, scale)
99
- when /^Decimal\((\d+),\s*(\d+)\)$/ then read_decimal_column($1.to_i, $2.to_i, num_rows)
100
-
101
- # Enums (stored as signed integers)
102
- when /^Enum8\(.+\)$/ then read_int8_column(num_rows)
103
- when /^Enum16\(.+\)$/ then read_int16_column(num_rows)
104
-
105
- # Nullable
106
- when /^Nullable\((.+)\)$/ then read_nullable_column($1, num_rows)
107
-
108
- # LowCardinality
109
- when /^LowCardinality\((.+)\)$/ then read_low_cardinality_column($1, num_rows)
110
-
111
- # Arrays
112
- when /^Array\((.+)\)$/ then read_array_column($1, num_rows)
113
-
114
- # Tuples
115
- when /^Tuple\((.*)\)$/ then read_tuple_column(parse_tuple_types($1), num_rows)
116
-
117
- # Maps
118
- when /^Map\((.+)\)$/
119
- types = parse_tuple_types($1)
120
- read_map_column(types[0], types[1], num_rows)
121
-
122
- else
123
- raise UnsupportedTypeError, "Unsupported column type: #{type}"
124
- end
125
- end
126
-
127
- # --- Bulk Column Readers ---
128
-
129
- def read_uint8_column(num_rows)
130
- @reader.read(num_rows).bytes
131
- end
132
-
133
- def read_uint16_column(num_rows)
134
- @reader.read(num_rows * 2).unpack("v*")
135
- end
136
-
137
- def read_uint32_column(num_rows)
138
- @reader.read(num_rows * 4).unpack("V*")
139
- end
140
-
141
- def read_uint64_column(num_rows)
142
- @reader.read(num_rows * 8).unpack("Q<*")
143
- end
144
-
145
- def read_uint128_column(num_rows)
146
- Array.new(num_rows) { read_le_bytes(16) }
147
- end
148
-
149
- def read_uint256_column(num_rows)
150
- Array.new(num_rows) { read_le_bytes(32) }
151
- end
152
-
153
- def read_int8_column(num_rows)
154
- @reader.read(num_rows).unpack("c*")
155
- end
156
-
157
- def read_int16_column(num_rows)
158
- @reader.read(num_rows * 2).unpack("s<*")
159
- end
160
-
161
- def read_int32_column(num_rows)
162
- @reader.read(num_rows * 4).unpack("l<*")
163
- end
164
-
165
- def read_int64_column(num_rows)
166
- @reader.read(num_rows * 8).unpack("q<*")
167
- end
168
-
169
- def read_int128_column(num_rows)
170
- Array.new(num_rows) { read_signed_le_bytes(16) }
171
- end
172
-
173
- def read_int256_column(num_rows)
174
- Array.new(num_rows) { read_signed_le_bytes(32) }
175
- end
176
-
177
- def read_float32_column(num_rows)
178
- @reader.read(num_rows * 4).unpack("e*")
179
- end
180
-
181
- def read_float64_column(num_rows)
182
- @reader.read(num_rows * 8).unpack("E*")
183
- end
184
-
185
- def read_bool_column(num_rows)
186
- @reader.read(num_rows).bytes.map { |b| b == 1 }
187
- end
188
-
189
- def read_string_column(num_rows)
190
- Array.new(num_rows) { read_string }
191
- end
192
-
193
- def read_fixed_string_column(length, num_rows)
194
- Array.new(num_rows) { @reader.read(length).force_encoding(Encoding::UTF_8) }
195
- end
196
-
197
- def read_date_column(num_rows)
198
- @reader.read(num_rows * 2).unpack("v*").map { |days| DATE_EPOCH + days }
199
- end
200
-
201
- def read_date32_column(num_rows)
202
- @reader.read(num_rows * 4).unpack("l<*").map { |days| DATE_EPOCH + days }
203
- end
204
-
205
- def read_datetime_column(num_rows)
206
- @reader.read(num_rows * 4).unpack("V*").map { |ts| Time.at(ts).utc }
207
- end
208
-
209
- def read_datetime64_column(precision, num_rows)
210
- scale = 10**(9 - precision)
211
- @reader.read(num_rows * 8).unpack("q<*").map do |ticks|
212
- nsec = ticks * scale
213
- Time.at(nsec / 1_000_000_000, nsec % 1_000_000_000, :nanosecond).utc
214
- end
215
- end
216
-
217
- def read_uuid_column(num_rows)
218
- Array.new(num_rows) { read_uuid }
219
- end
220
-
221
- def read_ipv4_column(num_rows)
222
- Array.new(num_rows) { read_ipv4 }
223
- end
224
-
225
- def read_ipv6_column(num_rows)
226
- Array.new(num_rows) { read_ipv6 }
227
- end
228
-
229
- def read_decimal_column(precision, scale, num_rows)
230
- divisor = 10**scale
231
- if precision <= 9
232
- @reader.read(num_rows * 4).unpack("l<*").map { |v| BigDecimal(v) / divisor }
233
- elsif precision <= 18
234
- @reader.read(num_rows * 8).unpack("q<*").map { |v| BigDecimal(v) / divisor }
235
- elsif precision <= 38
236
- Array.new(num_rows) { BigDecimal(read_signed_le_bytes(16)) / divisor }
237
- else
238
- Array.new(num_rows) { BigDecimal(read_signed_le_bytes(32)) / divisor }
239
- end
240
- end
241
-
242
- # --- Single Value Readers ---
243
-
244
- def read_varint
245
- result = 0
246
- shift = 0
247
- loop do
248
- byte = @reader.getbyte
249
- return result if byte.nil?
250
- result |= (byte & 0x7F) << shift
251
- break if (byte & 0x80) == 0
252
- shift += 7
253
- end
254
- result
255
- end
256
-
257
- def read_string
258
- @reader.read(read_varint).force_encoding(Encoding::UTF_8)
259
- end
260
-
261
- def read_uint64 = @reader.read(8).unpack1("Q<")
262
-
263
- def read_uuid
264
- first_half = @reader.read(8).bytes.reverse
265
- second_half = @reader.read(8).bytes.reverse
266
- hex = (first_half + second_half).pack("C*").unpack1("H*")
267
- "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
268
- end
269
-
270
- def read_ipv4
271
- bytes = @reader.read(4).unpack("C4").reverse
272
- IPAddr.new(bytes.join("."))
273
- end
274
-
275
- def read_ipv6
276
- bytes = @reader.read(16)
277
- IPAddr.new(bytes.unpack1("H*").scan(/.{4}/).join(":"), Socket::AF_INET6)
278
- end
279
-
280
- # --- Container Type Readers ---
281
-
282
- # Nullable: nulls mask (uint8 per row, 1=null), then all values
283
- def read_nullable_column(inner_type, num_rows)
284
- nulls = @reader.read(num_rows).bytes
285
- values = read_column(inner_type, num_rows)
286
- num_rows.times { |i| values[i] = nil if nulls[i] == 1 }
287
- values
288
- end
289
-
290
- # Array: cumulative offsets (uint64 per row), then all elements
291
- def read_array_column(inner_type, num_rows)
292
- offsets = read_uint64_column(num_rows)
293
- total_elements = offsets.last || 0
294
-
295
- return Array.new(num_rows) { [] } if total_elements == 0
296
-
297
- elements = read_column(inner_type, total_elements)
298
-
299
- arrays = Array.new(num_rows)
300
- prev_offset = 0
301
- offsets.each_with_index do |offset, i|
302
- arrays[i] = elements.slice(prev_offset, offset - prev_offset)
303
- prev_offset = offset
304
- end
305
- arrays
306
- end
307
-
308
- # Map: cumulative offsets (uint64 per row), then all keys, then all values
309
- def read_map_column(key_type, value_type, num_rows)
310
- offsets = read_uint64_column(num_rows)
311
- total_pairs = offsets.last || 0
312
-
313
- return Array.new(num_rows) { {} } if total_pairs == 0
314
-
315
- keys = read_column(key_type, total_pairs)
316
- values = read_column(value_type, total_pairs)
317
-
318
- maps = Array.new(num_rows)
319
- prev_offset = 0
320
- offsets.each_with_index do |offset, i|
321
- len = offset - prev_offset
322
- maps[i] = keys.slice(prev_offset, len).zip(values.slice(prev_offset, len)).to_h
323
- prev_offset = offset
324
- end
325
- maps
326
- end
327
-
328
- # Tuple: all values of element 0, then element 1, etc. (column-major)
329
- # Empty tuples send 1 byte per row.
330
- def read_tuple_column(element_types, num_rows)
331
- if element_types.empty?
332
- @reader.read(num_rows)
333
- return Array.new(num_rows) { [] }
334
- end
335
-
336
- element_columns = element_types.map { |type| read_column(type, num_rows) }
337
-
338
- Array.new(num_rows) { |i| element_columns.map { |col| col[i] } }
339
- end
340
-
341
- # LowCardinality: version, meta, dictionary, keys
342
- def read_low_cardinality_column(inner_type, num_rows)
343
- _version = read_uint64
344
- meta = read_uint64
345
- key_type = meta & 0xFF
346
-
347
- dict_size = read_uint64
348
- dictionary = read_column(inner_type, dict_size)
349
-
350
- _num_keys = read_uint64
351
- keys = case key_type
352
- when 0 then read_uint8_column(num_rows)
353
- when 1 then read_uint16_column(num_rows)
354
- when 2 then read_uint32_column(num_rows)
355
- else read_uint64_column(num_rows)
356
- end
357
-
358
- keys.map { |k| dictionary[k] }
359
- end
360
-
361
- # --- Helpers ---
362
-
363
- def read_le_bytes(num_bytes)
364
- bytes = @reader.read(num_bytes).bytes
365
- result = 0
366
- bytes.each_with_index { |b, i| result |= b << (8 * i) }
367
- result
368
- end
369
-
370
- def read_signed_le_bytes(num_bytes)
371
- value = read_le_bytes(num_bytes)
372
- max_positive = 1 << (num_bytes * 8 - 1)
373
- (value >= max_positive) ? value - (1 << (num_bytes * 8)) : value
374
- end
375
-
376
- def parse_tuple_types(types_str)
377
- types = []
378
- depth = 0
379
- current = +""
380
-
381
- types_str.each_char do |c|
382
- case c
383
- when "("
384
- depth += 1
385
- current << c
386
- when ")"
387
- depth -= 1
388
- current << c
389
- when ","
390
- if depth == 0
391
- types << current.strip
392
- current = +""
393
- else
394
- current << c
395
- end
396
- else
397
- current << c
398
- end
399
- end
400
-
401
- types << current.strip unless current.empty?
402
- types
403
- end
404
- end
405
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ChConnect
4
- # Immutable result from transport layer.
5
- # @api private
6
- #
7
- # @!attribute [r] body
8
- # @return [HTTP::Response::Body] response body
9
- # @!attribute [r] summary
10
- # @return [Hash] ClickHouse query summary
11
- TransportResult = Data.define(:body, :summary)
12
- end