clickhouse-ruby 0.1.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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE +21 -0
  4. data/README.md +251 -0
  5. data/lib/clickhouse_ruby/active_record/arel_visitor.rb +468 -0
  6. data/lib/clickhouse_ruby/active_record/connection_adapter.rb +723 -0
  7. data/lib/clickhouse_ruby/active_record/railtie.rb +192 -0
  8. data/lib/clickhouse_ruby/active_record/schema_statements.rb +693 -0
  9. data/lib/clickhouse_ruby/active_record.rb +121 -0
  10. data/lib/clickhouse_ruby/client.rb +471 -0
  11. data/lib/clickhouse_ruby/configuration.rb +145 -0
  12. data/lib/clickhouse_ruby/connection.rb +328 -0
  13. data/lib/clickhouse_ruby/connection_pool.rb +301 -0
  14. data/lib/clickhouse_ruby/errors.rb +144 -0
  15. data/lib/clickhouse_ruby/result.rb +189 -0
  16. data/lib/clickhouse_ruby/types/array.rb +183 -0
  17. data/lib/clickhouse_ruby/types/base.rb +77 -0
  18. data/lib/clickhouse_ruby/types/boolean.rb +68 -0
  19. data/lib/clickhouse_ruby/types/date_time.rb +163 -0
  20. data/lib/clickhouse_ruby/types/float.rb +115 -0
  21. data/lib/clickhouse_ruby/types/integer.rb +157 -0
  22. data/lib/clickhouse_ruby/types/low_cardinality.rb +58 -0
  23. data/lib/clickhouse_ruby/types/map.rb +249 -0
  24. data/lib/clickhouse_ruby/types/nullable.rb +73 -0
  25. data/lib/clickhouse_ruby/types/parser.rb +244 -0
  26. data/lib/clickhouse_ruby/types/registry.rb +148 -0
  27. data/lib/clickhouse_ruby/types/string.rb +83 -0
  28. data/lib/clickhouse_ruby/types/tuple.rb +206 -0
  29. data/lib/clickhouse_ruby/types/uuid.rb +84 -0
  30. data/lib/clickhouse_ruby/types.rb +69 -0
  31. data/lib/clickhouse_ruby/version.rb +5 -0
  32. data/lib/clickhouse_ruby.rb +101 -0
  33. metadata +150 -0
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ # Configuration for ClickhouseRuby client connections
5
+ #
6
+ # @example
7
+ # config = ClickhouseRuby::Configuration.new
8
+ # config.host = 'clickhouse.example.com'
9
+ # config.port = 8443
10
+ # config.ssl = true
11
+ #
12
+ class Configuration
13
+ # @return [String] the ClickHouse server hostname
14
+ attr_accessor :host
15
+
16
+ # @return [Integer] the ClickHouse HTTP port (default: 8123)
17
+ attr_accessor :port
18
+
19
+ # @return [String] the database name (default: 'default')
20
+ attr_accessor :database
21
+
22
+ # @return [String, nil] the username for authentication
23
+ attr_accessor :username
24
+
25
+ # @return [String, nil] the password for authentication
26
+ attr_accessor :password
27
+
28
+ # @return [Boolean] whether to use SSL/TLS
29
+ attr_accessor :ssl
30
+
31
+ # @return [Boolean] whether to verify SSL certificates (default: true)
32
+ # IMPORTANT: This defaults to true for security. Only disable in development.
33
+ attr_accessor :ssl_verify
34
+
35
+ # @return [String, nil] path to custom CA certificate file
36
+ attr_accessor :ssl_ca_path
37
+
38
+ # @return [Integer] connection timeout in seconds (default: 10)
39
+ attr_accessor :connect_timeout
40
+
41
+ # @return [Integer] read timeout in seconds (default: 60)
42
+ attr_accessor :read_timeout
43
+
44
+ # @return [Integer] write timeout in seconds (default: 60)
45
+ attr_accessor :write_timeout
46
+
47
+ # @return [Integer] connection pool size (default: 5)
48
+ attr_accessor :pool_size
49
+
50
+ # @return [Integer] time to wait for a pool connection in seconds (default: 5)
51
+ attr_accessor :pool_timeout
52
+
53
+ # @return [Logger, nil] logger instance for debugging
54
+ attr_accessor :logger
55
+
56
+ # @return [Symbol] log level (:debug, :info, :warn, :error)
57
+ attr_accessor :log_level
58
+
59
+ # @return [Hash] default ClickHouse settings for all queries
60
+ attr_accessor :default_settings
61
+
62
+ # Creates a new Configuration with sensible defaults
63
+ def initialize
64
+ @host = 'localhost'
65
+ @port = 8123
66
+ @database = 'default'
67
+ @username = nil
68
+ @password = nil
69
+ @ssl = false
70
+ @ssl_verify = true # SECURITY: Verify certificates by default
71
+ @ssl_ca_path = nil
72
+ @connect_timeout = 10
73
+ @read_timeout = 60
74
+ @write_timeout = 60
75
+ @pool_size = 5
76
+ @pool_timeout = 5
77
+ @logger = nil
78
+ @log_level = :info
79
+ @default_settings = {}
80
+ end
81
+
82
+ # Returns the base URL for HTTP connections
83
+ #
84
+ # @return [String] the base URL
85
+ def base_url
86
+ scheme = ssl ? 'https' : 'http'
87
+ "#{scheme}://#{host}:#{port}"
88
+ end
89
+
90
+ # Returns whether SSL should be used based on configuration or port
91
+ # Automatically enables SSL for common secure ports (8443, 443)
92
+ #
93
+ # @return [Boolean] whether to use SSL
94
+ def use_ssl?
95
+ return ssl unless ssl.nil?
96
+
97
+ # Auto-enable SSL for secure ports
98
+ [8443, 443].include?(port)
99
+ end
100
+
101
+ # Returns a hash suitable for creating HTTP connections
102
+ #
103
+ # @return [Hash] connection options
104
+ def to_connection_options
105
+ {
106
+ host: host,
107
+ port: port,
108
+ database: database,
109
+ username: username,
110
+ password: password,
111
+ use_ssl: use_ssl?,
112
+ ssl_verify: ssl_verify,
113
+ ssl_ca_path: ssl_ca_path,
114
+ connect_timeout: connect_timeout,
115
+ read_timeout: read_timeout,
116
+ write_timeout: write_timeout
117
+ }
118
+ end
119
+
120
+ # Creates a duplicate configuration
121
+ #
122
+ # @return [Configuration] a new configuration with the same settings
123
+ def dup
124
+ new_config = Configuration.new
125
+ instance_variables.each do |var|
126
+ value = instance_variable_get(var)
127
+ new_config.instance_variable_set(var, value.dup) rescue value
128
+ end
129
+ new_config
130
+ end
131
+
132
+ # Validates the configuration
133
+ #
134
+ # @raise [ConfigurationError] if the configuration is invalid
135
+ # @return [Boolean] true if valid
136
+ def validate!
137
+ raise ConfigurationError, 'host is required' if host.nil? || host.empty?
138
+ raise ConfigurationError, 'port must be a positive integer' unless port.is_a?(Integer) && port.positive?
139
+ raise ConfigurationError, 'database is required' if database.nil? || database.empty?
140
+ raise ConfigurationError, 'pool_size must be at least 1' unless pool_size >= 1
141
+
142
+ true
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'openssl'
6
+
7
+ module ClickhouseRuby
8
+ # Single HTTP connection wrapper for ClickHouse communication
9
+ #
10
+ # Provides a thin wrapper around Net::HTTP with:
11
+ # - SSL/TLS with verification ON by default (security best practice)
12
+ # - Configurable timeouts
13
+ # - Keep-alive support
14
+ # - Health check via ping
15
+ #
16
+ # @example Creating a connection
17
+ # connection = ClickhouseRuby::Connection.new(
18
+ # host: 'localhost',
19
+ # port: 8123,
20
+ # use_ssl: false
21
+ # )
22
+ # connection.ping # => true
23
+ #
24
+ # @example With SSL (verification enabled by default)
25
+ # connection = ClickhouseRuby::Connection.new(
26
+ # host: 'clickhouse.example.com',
27
+ # port: 8443,
28
+ # use_ssl: true,
29
+ # ssl_verify: true, # This is the default!
30
+ # ssl_ca_path: '/path/to/ca-bundle.crt'
31
+ # )
32
+ #
33
+ class Connection
34
+ # @return [String] the ClickHouse host
35
+ attr_reader :host
36
+
37
+ # @return [Integer] the ClickHouse port
38
+ attr_reader :port
39
+
40
+ # @return [String] the database name
41
+ attr_reader :database
42
+
43
+ # @return [String, nil] username for authentication
44
+ attr_reader :username
45
+
46
+ # @return [Boolean] whether SSL is enabled
47
+ attr_reader :use_ssl
48
+
49
+ # @return [Boolean] whether the connection is currently open
50
+ attr_reader :connected
51
+ alias connected? connected
52
+
53
+ # @return [Time, nil] when the connection was last used
54
+ attr_reader :last_used_at
55
+
56
+ # Creates a new connection
57
+ #
58
+ # @param host [String] ClickHouse server hostname
59
+ # @param port [Integer] ClickHouse HTTP port
60
+ # @param database [String] database name
61
+ # @param username [String, nil] username for authentication
62
+ # @param password [String, nil] password for authentication
63
+ # @param use_ssl [Boolean] whether to use SSL/TLS
64
+ # @param ssl_verify [Boolean] whether to verify SSL certificates (default: true)
65
+ # @param ssl_ca_path [String, nil] path to CA certificate file
66
+ # @param connect_timeout [Integer] connection timeout in seconds
67
+ # @param read_timeout [Integer] read timeout in seconds
68
+ # @param write_timeout [Integer] write timeout in seconds
69
+ def initialize(
70
+ host:,
71
+ port: 8123,
72
+ database: 'default',
73
+ username: nil,
74
+ password: nil,
75
+ use_ssl: false,
76
+ ssl_verify: true,
77
+ ssl_ca_path: nil,
78
+ connect_timeout: 10,
79
+ read_timeout: 60,
80
+ write_timeout: 60
81
+ )
82
+ @host = host
83
+ @port = port
84
+ @database = database
85
+ @username = username
86
+ @password = password
87
+ @use_ssl = use_ssl
88
+ @ssl_verify = ssl_verify
89
+ @ssl_ca_path = ssl_ca_path
90
+ @connect_timeout = connect_timeout
91
+ @read_timeout = read_timeout
92
+ @write_timeout = write_timeout
93
+
94
+ @http = nil
95
+ @connected = false
96
+ @last_used_at = nil
97
+ @mutex = Mutex.new
98
+ end
99
+
100
+ # Establishes the HTTP connection
101
+ #
102
+ # @return [self]
103
+ # @raise [ConnectionNotEstablished] if connection fails
104
+ # @raise [SSLError] if SSL handshake fails
105
+ def connect
106
+ @mutex.synchronize do
107
+ return self if @connected && @http&.started?
108
+
109
+ begin
110
+ @http = build_http
111
+ @http.start
112
+ @connected = true
113
+ @last_used_at = Time.now
114
+ rescue OpenSSL::SSL::SSLError => e
115
+ @connected = false
116
+ raise SSLError.new(
117
+ "SSL connection failed: #{e.message}",
118
+ original_error: e
119
+ )
120
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
121
+ @connected = false
122
+ raise ConnectionNotEstablished.new(
123
+ "Failed to connect to #{@host}:#{@port}: #{e.message}",
124
+ original_error: e
125
+ )
126
+ rescue Net::OpenTimeout => e
127
+ @connected = false
128
+ raise ConnectionTimeout.new(
129
+ "Connection timeout to #{@host}:#{@port}",
130
+ original_error: e
131
+ )
132
+ end
133
+ end
134
+
135
+ self
136
+ end
137
+
138
+ # Closes the HTTP connection
139
+ #
140
+ # @return [self]
141
+ def disconnect
142
+ @mutex.synchronize do
143
+ if @http&.started?
144
+ @http.finish rescue nil
145
+ end
146
+ @http = nil
147
+ @connected = false
148
+ end
149
+
150
+ self
151
+ end
152
+
153
+ # Reconnects by closing and reopening the connection
154
+ #
155
+ # @return [self]
156
+ def reconnect
157
+ disconnect
158
+ connect
159
+ end
160
+
161
+ # Executes an HTTP POST request
162
+ #
163
+ # @param path [String] the request path
164
+ # @param body [String] the request body (SQL query)
165
+ # @param headers [Hash] additional headers
166
+ # @return [Net::HTTPResponse] the response
167
+ # @raise [ConnectionNotEstablished] if not connected
168
+ # @raise [ConnectionTimeout] if request times out
169
+ def post(path, body, headers = {})
170
+ ensure_connected
171
+
172
+ request = Net::HTTP::Post.new(path)
173
+ request.body = body
174
+
175
+ # Set default headers
176
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
177
+ request['Accept'] = 'application/json'
178
+ request['User-Agent'] = "ClickhouseRuby/#{ClickhouseRuby::VERSION} Ruby/#{RUBY_VERSION}"
179
+
180
+ # Add authentication
181
+ if @username
182
+ request.basic_auth(@username, @password || '')
183
+ end
184
+
185
+ # Merge custom headers
186
+ headers.each { |k, v| request[k] = v }
187
+
188
+ execute_request(request)
189
+ end
190
+
191
+ # Executes an HTTP GET request
192
+ #
193
+ # @param path [String] the request path
194
+ # @param headers [Hash] additional headers
195
+ # @return [Net::HTTPResponse] the response
196
+ def get(path, headers = {})
197
+ ensure_connected
198
+
199
+ request = Net::HTTP::Get.new(path)
200
+ request['Accept'] = 'application/json'
201
+ request['User-Agent'] = "ClickhouseRuby/#{ClickhouseRuby::VERSION} Ruby/#{RUBY_VERSION}"
202
+
203
+ if @username
204
+ request.basic_auth(@username, @password || '')
205
+ end
206
+
207
+ headers.each { |k, v| request[k] = v }
208
+
209
+ execute_request(request)
210
+ end
211
+
212
+ # Checks if ClickHouse is reachable and responsive
213
+ #
214
+ # @return [Boolean] true if ClickHouse responds to ping
215
+ def ping
216
+ connect unless connected?
217
+
218
+ response = get('/ping')
219
+ response.code == '200' && response.body&.strip == 'Ok.'
220
+ rescue StandardError
221
+ false
222
+ end
223
+
224
+ # Returns whether the connection is healthy
225
+ #
226
+ # @return [Boolean] true if connected and HTTP connection is active
227
+ def healthy?
228
+ @connected && @http&.started?
229
+ end
230
+
231
+ # Returns whether the connection has been idle too long
232
+ #
233
+ # @param max_idle_seconds [Integer] maximum idle time in seconds
234
+ # @return [Boolean] true if connection is stale
235
+ def stale?(max_idle_seconds = 300)
236
+ return true unless @last_used_at
237
+
238
+ Time.now - @last_used_at > max_idle_seconds
239
+ end
240
+
241
+ # Returns a string representation of the connection
242
+ #
243
+ # @return [String]
244
+ def inspect
245
+ scheme = @use_ssl ? 'https' : 'http'
246
+ status = @connected ? 'connected' : 'disconnected'
247
+ "#<#{self.class.name} #{scheme}://#{@host}:#{@port} #{status}>"
248
+ end
249
+
250
+ private
251
+
252
+ # Builds the Net::HTTP instance with proper configuration
253
+ #
254
+ # @return [Net::HTTP]
255
+ def build_http
256
+ http = Net::HTTP.new(@host, @port)
257
+
258
+ # Timeouts
259
+ http.open_timeout = @connect_timeout
260
+ http.read_timeout = @read_timeout
261
+ http.write_timeout = @write_timeout
262
+
263
+ # SSL configuration
264
+ if @use_ssl
265
+ http.use_ssl = true
266
+
267
+ # SECURITY: Enable SSL verification by default
268
+ # This is critical - existing gems disable this which is a vulnerability
269
+ if @ssl_verify
270
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
271
+ http.ca_file = @ssl_ca_path if @ssl_ca_path
272
+ else
273
+ # Only disable if explicitly requested (development only!)
274
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
275
+ end
276
+
277
+ # Use modern TLS versions
278
+ http.min_version = OpenSSL::SSL::TLS1_2_VERSION
279
+ end
280
+
281
+ # Enable keep-alive
282
+ http.keep_alive_timeout = 30
283
+
284
+ http
285
+ end
286
+
287
+ # Ensures the connection is established
288
+ #
289
+ # @raise [ConnectionNotEstablished] if not connected
290
+ def ensure_connected
291
+ unless @connected && @http&.started?
292
+ connect
293
+ end
294
+ end
295
+
296
+ # Executes an HTTP request with error handling
297
+ #
298
+ # @param request [Net::HTTPRequest] the request to execute
299
+ # @return [Net::HTTPResponse]
300
+ def execute_request(request)
301
+ @mutex.synchronize do
302
+ begin
303
+ response = @http.request(request)
304
+ @last_used_at = Time.now
305
+ response
306
+ rescue Net::ReadTimeout => e
307
+ @connected = false
308
+ raise ConnectionTimeout.new(
309
+ "Read timeout: #{e.message}",
310
+ original_error: e
311
+ )
312
+ rescue Net::WriteTimeout => e
313
+ @connected = false
314
+ raise ConnectionTimeout.new(
315
+ "Write timeout: #{e.message}",
316
+ original_error: e
317
+ )
318
+ rescue Errno::ECONNRESET, Errno::EPIPE, IOError => e
319
+ @connected = false
320
+ raise ConnectionError.new(
321
+ "Connection lost: #{e.message}",
322
+ original_error: e
323
+ )
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end