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.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ abort "ch_connect requires MRI Ruby" unless RUBY_ENGINE == "ruby"
4
+
5
+ require "mkmf"
6
+
7
+ vendor_dir = File.expand_path("../../vendor/clickhouse-c", __dir__)
8
+ $INCFLAGS << " -I#{vendor_dir}"
9
+
10
+ append_cflags("-O2")
11
+ append_cflags("-std=c11")
12
+
13
+ # Help mkmf find Homebrew-installed libraries on macOS.
14
+ if RUBY_PLATFORM.include?("darwin")
15
+ %w[lz4 zstd].each do |pkg|
16
+ prefix = `brew --prefix #{pkg} 2>/dev/null`.strip
17
+ next if prefix.empty? || !File.directory?(prefix)
18
+
19
+ $INCFLAGS << " -I#{prefix}/include"
20
+ $LDFLAGS << " -L#{prefix}/lib"
21
+ end
22
+ end
23
+
24
+ have_lz4 = have_header("lz4.h") && have_library("lz4", "LZ4_decompress_safe")
25
+ have_zstd = have_header("zstd.h") && have_library("zstd", "ZSTD_decompress")
26
+
27
+ $defs << (have_lz4 ? "-DCHC_EXT_HAVE_LZ4=1" : "-DCHC_NO_LZ4")
28
+ $defs << (have_zstd ? "-DCHC_EXT_HAVE_ZSTD=1" : "-DCHC_NO_ZSTD")
29
+
30
+ message "ch_connect_native: lz4=#{have_lz4} zstd=#{have_zstd} (TLS is handled in Ruby via openssl stdlib)\n"
31
+
32
+ create_makefile("ch_connect/ch_connect_native")
@@ -10,26 +10,37 @@ module ChConnect
10
10
  #
11
11
  # @example Using URL
12
12
  # config = ChConnect::Config.new
13
- # config.url = "http://user:pass@localhost:8123/mydb"
13
+ # config.url = "clickhouse://user:pass@localhost:9000/mydb"
14
14
  class Config
15
+ URL_SCHEMES = {
16
+ "clickhouse" => false,
17
+ "tcp" => false,
18
+ "clickhouses" => true,
19
+ "tcps" => true
20
+ }.freeze
21
+
15
22
  DEFAULTS = {
16
- scheme: "http",
17
23
  host: "localhost",
18
- port: 8123,
24
+ port: nil,
25
+ compression: :lz4,
26
+ ssl: false,
27
+ ssl_verify: true,
28
+ ssl_ca: nil,
19
29
  database: "default",
20
- username: "",
30
+ username: "default",
21
31
  password: "",
22
32
  connection_timeout: 5,
23
33
  read_timeout: 60,
24
34
  write_timeout: 60,
25
- keep_alive_timeout: 8,
35
+ keep_alive_timeout: 60,
26
36
  pool_size: 100,
27
37
  pool_timeout: 5,
28
38
  max_retries: 3,
39
+ retry_base_interval: 0.05,
40
+ retry_max_interval: 1.0,
29
41
  instrumenter: NullInstrumenter.new
30
42
  }.freeze
31
43
 
32
- # @return [String] URL scheme (http or https)
33
44
  # @return [String] ClickHouse server hostname
34
45
  # @return [Integer] ClickHouse server port
35
46
  # @return [String] Database name
@@ -38,47 +49,77 @@ module ChConnect
38
49
  # @return [Integer] Connection timeout in seconds
39
50
  # @return [Integer] Read timeout in seconds
40
51
  # @return [Integer] Write timeout in seconds
41
- # @return [Integer] Keep-alive timeout for idle persistent connections in seconds
52
+ # @return [Numeric, nil] Maximum pooled connection idle time in seconds
42
53
  # @return [Integer] Connection pool size
43
54
  # @return [Integer] Pool checkout timeout in seconds
44
- # @return [Integer] Max retry attempts on connection errors
55
+ # @return [Integer] Max retries for establishment failures and opted-in
56
+ # idempotent query transport failures
57
+ # @return [Numeric] Initial retry delay in seconds; zero disables backoff
58
+ # @return [Numeric] Maximum retry delay in seconds
45
59
  # @return [#instrument] Instrumenter for query instrumentation
46
- attr_accessor :scheme, :host, :port, :database, :username, :password, :connection_timeout, :read_timeout, :write_timeout, :keep_alive_timeout, :pool_size, :pool_timeout, :max_retries, :instrumenter
60
+ # @return [Integer] Native protocol port
61
+ # @return [Symbol, nil] Block compression: :lz4 (default), :zstd or nil
62
+ # @return [Boolean] Use TLS (default: false)
63
+ # @return [Boolean] Verify the server certificate when ssl is enabled (default: true)
64
+ # @return [String, nil] Path to a CA certificate file for TLS verification (default: system CA store)
65
+ attr_accessor :compression, :ssl, :ssl_verify, :ssl_ca, :host, :database, :username, :password, :connection_timeout, :read_timeout, :write_timeout, :keep_alive_timeout, :pool_size, :pool_timeout, :max_retries, :retry_base_interval, :retry_max_interval, :instrumenter
66
+ attr_writer :port
47
67
 
48
68
  # Creates a new configuration instance.
49
69
  #
50
70
  # @param params [Hash] configuration options
51
- # @option params [String] :scheme URL scheme (default: "http")
52
71
  # @option params [String] :host server hostname (default: "localhost")
53
- # @option params [Integer] :port server port (default: 8123)
72
+ # @option params [Integer, nil] :port native endpoint port (default: 9000, or 9440 with TLS)
54
73
  # @option params [String] :database database name (default: "default")
55
- # @option params [String] :username authentication username (default: "")
74
+ # @option params [String] :username authentication username (default: "default")
56
75
  # @option params [String] :password authentication password (default: "")
57
76
  # @option params [Integer] :connection_timeout connection timeout in seconds (default: 5)
58
77
  # @option params [Integer] :read_timeout read timeout in seconds (default: 60)
59
78
  # @option params [Integer] :write_timeout write timeout in seconds (default: 60)
60
- # @option params [Integer] :keep_alive_timeout idle persistent connection timeout in seconds (default: 8)
79
+ # @option params [Numeric, nil] :keep_alive_timeout pooled TCP connection idle timeout (default: 60)
61
80
  # @option params [Integer] :pool_size connection pool size (default: 100)
62
81
  # @option params [Integer] :pool_timeout pool checkout timeout (default: 5)
63
- # @option params [Integer] :max_retries max retry attempts on connection errors (default: 3)
82
+ # @option params [Integer] :max_retries max retries for establishment
83
+ # failures and opted-in idempotent query transport failures (default: 3)
84
+ # @option params [Numeric] :retry_base_interval initial retry delay in
85
+ # seconds, with exponential backoff and jitter (default: 0.05)
86
+ # @option params [Numeric] :retry_max_interval maximum retry delay in
87
+ # seconds (default: 1.0)
64
88
  def initialize(params = {})
65
89
  DEFAULTS.merge(params).each do |key, value|
66
90
  send("#{key}=", value)
67
91
  end
68
92
  end
69
93
 
94
+ # Returns the explicitly configured port or the default for the active
95
+ # TLS mode.
96
+ def port
97
+ @port || default_port
98
+ end
99
+
70
100
  # Sets configuration from a URL string.
71
101
  #
72
102
  # @param url [String] ClickHouse connection URL
73
103
  # @return [void]
74
104
  def url=(url)
75
105
  uri = URI(url)
76
- @scheme = uri.scheme
77
- @host = uri.host
106
+ ssl = URL_SCHEMES.fetch(uri.scheme) do
107
+ raise ArgumentError, "unsupported ClickHouse URL scheme: #{uri.scheme.inspect}"
108
+ end
109
+
110
+ @ssl = ssl
78
111
  @port = uri.port
79
- @database = uri.path.delete_prefix("/")
80
- @username = uri.user
81
- @password = uri.password
112
+ @host = uri.host
113
+ database = uri.path.delete_prefix("/")
114
+ @database = database unless database.empty?
115
+ @username = uri.user if uri.user
116
+ @password = uri.password if uri.password
117
+ end
118
+
119
+ private
120
+
121
+ def default_port
122
+ ssl ? 9440 : 9000
82
123
  end
83
124
  end
84
125
  end
@@ -1,12 +1,233 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "connection_pool"
4
+ require "openssl"
5
+ require "socket"
6
+
3
7
  module ChConnect
4
- # A single connection to ClickHouse server.
8
+ # A pooled ClickHouse connection built on the clickhouse-c ioless client:
9
+ # the C extension is a pure protocol state machine + block decoder, and all
10
+ # socket I/O, TLS, and timeouts live here in Ruby. Result blocks are parsed
11
+ # in C.
5
12
  #
6
- # @example
7
- # conn = ChConnect::Connection.new
8
- # response = conn.query("SELECT * FROM users WHERE id = 1")
13
+ # Maintains a pool of connections (config.pool_size); the native protocol
14
+ # handles one query at a time per connection, so concurrent queries each
15
+ # check out their own connection.
9
16
  class Connection
17
+ # Connection establishment failures are safe to retry because no query
18
+ # bytes have been sent to the server yet.
19
+ class EstablishmentError < ConnectionError; end
20
+ private_constant :EstablishmentError
21
+
22
+ COMPRESSION_AVAILABLE = {
23
+ nil => true,
24
+ :lz4 => NativeClient::LZ4_AVAILABLE,
25
+ :zstd => NativeClient::ZSTD_AVAILABLE
26
+ }.freeze
27
+
28
+ READ_CHUNK = 64 * 1024
29
+ WRITE_CHUNK = 64 * 1024
30
+ PARAM_ESCAPES = {
31
+ "\0" => "\\0", "\a" => "\\a", "\b" => "\\b", "\e" => "\\e",
32
+ "\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t",
33
+ "\v" => "\\v", "'" => "\\'", "\\" => "\\\\"
34
+ }.freeze
35
+ PARAM_ESCAPE_PATTERN = /[\0\a\b\e\f\n\r\t\v'\\]/
36
+ RESERVED_SETTINGS = {
37
+ "network_compression_method" => "set config.compression instead",
38
+ "output_format_native_encode_types_in_binary_format" => "required by the native decoder"
39
+ }.freeze
40
+
41
+ # A pooled slot owning one socket + protocol state machine pair.
42
+ # Connects lazily and replaces the connection when it is broken or was
43
+ # abandoned mid-query (interrupt, timeout, killed thread) — the C client
44
+ # tracks both via broken?.
45
+ # @api private
46
+ class Slot
47
+ def initialize(config)
48
+ @config = config
49
+ @socket = nil
50
+ @client = nil
51
+ @pid = Process.pid
52
+ @read_buf = String.new(capacity: READ_CHUNK)
53
+ end
54
+
55
+ def query(sql, params, settings)
56
+ ensure_connected
57
+
58
+ @client.send_query(sql, params, settings)
59
+ flush_output
60
+ loop do
61
+ case @client.recv_step
62
+ when :done then break
63
+ when :want_read then @client.feed(read_chunk)
64
+ end
65
+ end
66
+ @client.take_result
67
+ rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
68
+ raise ConnectionError, "#{e.class}: #{e.message}"
69
+ end
70
+
71
+ def broken? = !@client || @client.broken? || @socket&.closed?
72
+
73
+ def close
74
+ if @pid != Process.pid && @socket.is_a?(OpenSSL::SSL::SSLSocket)
75
+ # A fork duplicates the fd. Closing the SSLSocket would send a TLS
76
+ # close_notify on the parent's live session; close only this process's
77
+ # raw fd instead.
78
+ safe_close(@socket.to_io)
79
+ else
80
+ safe_close(@socket)
81
+ end
82
+ @client&.close
83
+ @socket = nil
84
+ @client = nil
85
+ end
86
+
87
+ private
88
+
89
+ def ensure_connected
90
+ if @client
91
+ raise EstablishmentError, "pooled connection is broken" if broken?
92
+
93
+ return
94
+ end
95
+
96
+ establish_connection
97
+ end
98
+
99
+ def establish_connection
100
+ @socket = connect_socket
101
+ @client = NativeClient.new(
102
+ @config.database,
103
+ @config.username,
104
+ @config.password,
105
+ @config.compression
106
+ )
107
+ handshake
108
+ rescue ConnectionError, IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
109
+ close
110
+ raise EstablishmentError, "#{e.class}: #{e.message}"
111
+ end
112
+
113
+ def connect_socket
114
+ socket = Socket.tcp(@config.host, @config.port, connect_timeout: @config.connection_timeout)
115
+ wrapped = nil
116
+ begin
117
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
118
+ wrapped = @config.ssl ? tls_wrap(socket) : socket
119
+ ensure
120
+ # a failed TLS handshake would otherwise leak the raw socket:
121
+ # @socket is not assigned yet, so discard has nothing to close.
122
+ # (ensure, not rescue: Thread#kill / Timeout skip rescue clauses)
123
+ safe_close(socket) if wrapped.nil?
124
+ end
125
+ wrapped
126
+ end
127
+
128
+ def tls_wrap(socket)
129
+ ctx = OpenSSL::SSL::SSLContext.new
130
+ if @config.ssl_verify
131
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
132
+ ctx.verify_hostname = true
133
+ if @config.ssl_ca
134
+ ctx.ca_file = @config.ssl_ca
135
+ else
136
+ ctx.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
137
+ end
138
+ else
139
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
140
+ end
141
+
142
+ ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
143
+ ssl.hostname = @config.host
144
+ ssl.sync_close = true
145
+
146
+ deadline = monotonic_now + @config.connection_timeout
147
+ loop do
148
+ readiness = ssl.connect_nonblock(exception: false)
149
+ break unless readiness == :wait_readable || readiness == :wait_writable
150
+
151
+ wait_or_fail(ssl, readiness, deadline - monotonic_now, "TLS handshake timeout")
152
+ end
153
+ ssl
154
+ end
155
+
156
+ def handshake
157
+ deadline = monotonic_now + @config.connection_timeout
158
+ loop do
159
+ state = @client.handshake_step
160
+ flush_output
161
+ break if state == :done
162
+
163
+ # the handshake is part of connecting: bound it by connection_timeout
164
+ remaining = deadline - monotonic_now
165
+ raise ConnectionError, "native handshake timeout" if remaining <= 0
166
+
167
+ @client.feed(read_chunk(remaining))
168
+ end
169
+ end
170
+
171
+ def flush_output
172
+ deadline = monotonic_now + @config.write_timeout if @config.write_timeout
173
+ while (out = @client.take_output)
174
+ offset = 0
175
+ while offset < out.bytesize
176
+ if deadline && deadline - monotonic_now <= 0
177
+ raise ConnectionError, "write timeout"
178
+ end
179
+
180
+ pending = out.byteslice(offset, [WRITE_CHUNK, out.bytesize - offset].min)
181
+ case (written = @socket.write_nonblock(pending, exception: false))
182
+ when :wait_readable, :wait_writable
183
+ remaining = deadline && (deadline - monotonic_now)
184
+ wait_or_fail(@socket, written, remaining, "write timeout")
185
+ else
186
+ raise ConnectionError, "connection closed while writing" if written == 0
187
+
188
+ offset += written
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ # Reads into a reusable buffer: feed copies the bytes synchronously,
195
+ # so the buffer never needs to survive past the next read.
196
+ def read_chunk(timeout = @config.read_timeout)
197
+ deadline = monotonic_now + timeout if timeout
198
+ loop do
199
+ case (chunk = @socket.read_nonblock(READ_CHUNK, @read_buf, exception: false))
200
+ when :wait_readable, :wait_writable
201
+ remaining = deadline && (deadline - monotonic_now)
202
+ wait_or_fail(@socket, chunk, remaining, "read timeout")
203
+ when nil
204
+ raise ConnectionError, "connection closed by server"
205
+ else
206
+ return chunk
207
+ end
208
+ end
209
+ end
210
+
211
+ # readiness is :wait_readable / :wait_writable — also the names of the
212
+ # IO wait methods.
213
+ def wait_or_fail(io, readiness, timeout, message)
214
+ raise ConnectionError, message if timeout && timeout <= 0
215
+
216
+ raise ConnectionError, message unless io.to_io.public_send(readiness, timeout)
217
+ end
218
+
219
+ def monotonic_now
220
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
221
+ end
222
+
223
+ def safe_close(io)
224
+ io&.close
225
+ rescue IOError, SystemCallError
226
+ nil
227
+ end
228
+ end
229
+ private_constant :Slot
230
+
10
231
  # @return [Config] the configuration used by this connection
11
232
  attr_reader :config
12
233
 
@@ -14,22 +235,170 @@ module ChConnect
14
235
  #
15
236
  # @param config [Config] configuration instance (defaults to global config)
16
237
  def initialize(config = ChConnect.config)
17
- @config = config
18
- @transport = HttpTransport.new(config)
238
+ @config = config.dup.freeze
239
+ validate_compression!
240
+ @codec_name = @config.compression&.to_s
241
+ @pool = ConnectionPool.new(
242
+ size: @config.pool_size,
243
+ timeout: @config.pool_timeout,
244
+ auto_reload_after_fork: true
245
+ ) do
246
+ Slot.new(@config)
247
+ end
19
248
  end
20
249
 
21
- # Executes a SQL query and returns the response.
250
+ # Executes and instruments a SQL query.
22
251
  #
23
252
  # @param sql [String] SQL query to execute
24
- # @param options [Hash] query options
25
- # @option options [Hash] :params query parameters
26
- # @return [Response] query response with rows, columns, and metadata
253
+ # @param params [Hash, nil] query parameters (name => value)
254
+ # @param settings [Hash, nil] per-query ClickHouse settings
255
+ # @param idempotent [Boolean] retry transport failures on a fresh connection
256
+ # up to config.max_retries (default: false)
257
+ # @return [Response] fully parsed response
27
258
  # @raise [QueryError] if the query fails
28
- def query(sql, options = {})
259
+ # @raise [ConnectionError] if a connection or transport operation fails
260
+ def query(sql, params: nil, settings: nil, idempotent: false)
29
261
  @config.instrumenter.instrument("query.clickhouse", {sql: sql}) do
30
- result = @transport.execute(sql, options)
31
- NativeFormatParser.new(result.body).parse.with(summary: result.summary)
262
+ execute_query(sql, params, settings, idempotent)
263
+ end
264
+ end
265
+
266
+ # Closes all pooled connections.
267
+ #
268
+ # @return [void]
269
+ def close
270
+ @pool.shutdown(&:close)
271
+ end
272
+
273
+ private
274
+
275
+ def execute_query(sql, user_params, user_settings, idempotent)
276
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
277
+ params = format_params(user_params)
278
+ settings = format_settings(user_settings)
279
+ retries = 0
280
+ reap_idle_connections
281
+
282
+ columns, types, rows, summary = begin
283
+ @pool.with do |slot|
284
+ slot.query(sql, params, settings)
285
+ ensure
286
+ @pool.discard_current_connection(&:close) if slot.broken?
287
+ end
288
+ rescue EstablishmentError
289
+ raise if retries >= @config.max_retries
290
+ retries += 1
291
+ backoff_before_retry(retries)
292
+ retry
293
+ rescue ConnectionError
294
+ raise unless idempotent
295
+ raise if retries >= @config.max_retries
296
+ retries += 1
297
+ backoff_before_retry(retries)
298
+ retry
299
+ rescue ConnectionPool::TimeoutError => e
300
+ raise ConnectionError, "could not obtain a TCP connection from the pool: #{e.message}"
301
+ end
302
+ summary[:client_elapsed_ns] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started_at
303
+ Response.new(columns: columns, types: types, rows: rows, summary: summary)
304
+ end
305
+
306
+ def reap_idle_connections
307
+ timeout = @config.keep_alive_timeout
308
+ @pool.reap(idle_seconds: timeout, &:close) if timeout
309
+ end
310
+
311
+ def backoff_before_retry(retry_number)
312
+ base = @config.retry_base_interval
313
+ return unless base.positive?
314
+
315
+ ceiling = [base * (2**(retry_number - 1)), @config.retry_max_interval].min
316
+ sleep(ceiling * (0.5 + rand * 0.5))
317
+ end
318
+
319
+ def validate_compression!
320
+ available = COMPRESSION_AVAILABLE.fetch(@config.compression) do
321
+ raise Error, "unknown compression: #{@config.compression.inspect} (use :lz4, :zstd or nil)"
322
+ end
323
+ compression = @config.compression
324
+ unless available
325
+ raise Error, "compression = #{compression.inspect} but the extension was built without lib#{compression} (install #{compression} and reinstall the gem, or set config.compression = nil)"
326
+ end
327
+ end
328
+
329
+ # Converts params into native protocol substitutions. Values go through
330
+ # Field::restoreFromDump on the server, so everything is sent as a quoted
331
+ # string and converted by the placeholder type (same convention as
332
+ # clickhouse-cpp).
333
+ def format_params(params)
334
+ return nil if params.nil? || params.empty?
335
+
336
+ params.map { |name, value| [name.to_s, quote_param(value)] }
337
+ end
338
+
339
+ def quote_param(value)
340
+ # Query parameters use Field::restoreFromDump. Its nullable marker is a
341
+ # quoted, doubly escaped text NULL, matching ClickHouse's native client
342
+ # protocol (the dump parser consumes the first escape layer).
343
+ return "'\\\\N'" if value.nil?
344
+
345
+ inner_dump = value.is_a?(Array) ? dump_array_value(value) : escape_param_string(value.to_s)
346
+ outer_dump = inner_dump.gsub(/['\\]/) { |char| "\\#{char}" }
347
+ "'".b << outer_dump << "'"
348
+ end
349
+
350
+ def dump_array_value(value)
351
+ case value
352
+ when nil then "NULL"
353
+ when Array
354
+ dump = +"[".b
355
+ value.each_with_index do |element, index|
356
+ dump << "," unless index.zero?
357
+ dump << dump_array_value(element)
358
+ end
359
+ dump << "]"
360
+ when String, Symbol then quote_array_string(value.to_s)
361
+ when Time, DateTime
362
+ raise ArgumentError, "#{value.class} array parameters are ambiguous; pass a formatted String"
363
+ when Date then quote_array_string(value.to_s)
364
+ when true then "true"
365
+ when false then "false"
366
+ when Integer, Float, BigDecimal then value.to_s
367
+ else
368
+ raise ArgumentError, "unsupported array parameter element: #{value.class}"
369
+ end
370
+ end
371
+
372
+ def quote_array_string(value)
373
+ "'".b << escape_param_string(value) << "'"
374
+ end
375
+
376
+ def escape_param_string(value)
377
+ value.b.gsub(PARAM_ESCAPE_PATTERN, PARAM_ESCAPES)
378
+ end
379
+
380
+ # Settings travel as name/value strings in the Query packet. The wire's
381
+ # compression flag is boolean and the server picks the response codec
382
+ # from network_compression_method, so when compression is on we set its
383
+ # default to the configured codec.
384
+ def format_settings(user_settings)
385
+ settings = []
386
+ settings << ["network_compression_method", @codec_name] if @codec_name
387
+
388
+ user_settings&.each do |key, value|
389
+ name = key.to_s
390
+ if (guidance = RESERVED_SETTINGS[name])
391
+ raise ArgumentError, "setting #{name.inspect} is managed by ch_connect; #{guidance}"
392
+ end
393
+ formatted = case value
394
+ when true then "1"
395
+ when false then "0"
396
+ else value.to_s
397
+ end
398
+ settings << [name, formatted]
32
399
  end
400
+
401
+ settings.empty? ? nil : settings
33
402
  end
34
403
  end
35
404
  end
@@ -11,14 +11,6 @@ module ChConnect
11
11
  Response = Data.define(:columns, :types, :rows, :summary) do
12
12
  include Enumerable
13
13
 
14
- # @param columns [Array<Symbol>] column names
15
- # @param types [Array<Symbol>] column types
16
- # @param rows [Array<Array>] row data
17
- # @param summary [Hash, nil] ClickHouse query summary with symbol keys
18
- def initialize(columns: [], types: [], rows: [], summary: nil)
19
- super
20
- end
21
-
22
14
  # Iterates over rows as hashes with symbol keys.
23
15
  # @yield [Hash] each row as a hash
24
16
  # @return [Enumerator] if no block given
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChConnect
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/ch_connect.rb CHANGED
@@ -3,27 +3,18 @@
3
3
  require_relative "ch_connect/version"
4
4
  require_relative "ch_connect/null_instrumenter"
5
5
  require_relative "ch_connect/config"
6
- require_relative "ch_connect/transport_result"
7
- require_relative "ch_connect/http_transport"
8
- require_relative "ch_connect/connection"
9
6
  require_relative "ch_connect/response"
10
- require_relative "ch_connect/body_reader"
11
- require_relative "ch_connect/native_format_parser"
12
7
 
13
8
  # Ruby client for ClickHouse database with Native format support.
14
9
  #
15
10
  # @example Basic usage
16
11
  # ChConnect.configure do |config|
17
12
  # config.host = "localhost"
18
- # config.port = 8123
13
+ # config.port = 9000
19
14
  # end
20
15
  #
21
16
  # conn = ChConnect::Connection.new
22
17
  # response = conn.query("SELECT 1")
23
- #
24
- # @example Using connection pool
25
- # pool = ChConnect::Pool.new
26
- # response = pool.query("SELECT * FROM users")
27
18
  module ChConnect
28
19
  # Base error class for all ChConnect errors
29
20
  class Error < StandardError; end
@@ -31,6 +22,9 @@ module ChConnect
31
22
  # Raised when a query fails (syntax error, unknown table, etc.)
32
23
  class QueryError < Error; end
33
24
 
25
+ # Raised on network/connection failures
26
+ class ConnectionError < Error; end
27
+
34
28
  # Raised when encountering an unsupported ClickHouse data type
35
29
  class UnsupportedTypeError < Error; end
36
30
 
@@ -46,6 +40,15 @@ module ChConnect
46
40
  # @yield [Config] the configuration instance
47
41
  # @return [void]
48
42
  def self.configure
49
- yield(config) if block_given?
43
+ yield config
50
44
  end
51
45
  end
46
+
47
+ begin
48
+ require "ch_connect/ch_connect_native"
49
+ rescue LoadError => e
50
+ raise ChConnect::Error, "ch_connect requires the compiled native extension: #{e.message}"
51
+ end
52
+ ChConnect.private_constant :NativeClient
53
+
54
+ require_relative "ch_connect/connection"