clickhouse-ruby 0.1.0 → 0.2.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/CHANGELOG.md +74 -1
- data/README.md +165 -79
- data/lib/clickhouse_ruby/active_record/arel_visitor.rb +205 -76
- data/lib/clickhouse_ruby/active_record/connection_adapter.rb +103 -98
- data/lib/clickhouse_ruby/active_record/railtie.rb +20 -15
- data/lib/clickhouse_ruby/active_record/relation_extensions.rb +398 -0
- data/lib/clickhouse_ruby/active_record/schema_statements.rb +90 -104
- data/lib/clickhouse_ruby/active_record.rb +24 -10
- data/lib/clickhouse_ruby/client.rb +181 -74
- data/lib/clickhouse_ruby/configuration.rb +51 -10
- data/lib/clickhouse_ruby/connection.rb +180 -64
- data/lib/clickhouse_ruby/connection_pool.rb +25 -19
- data/lib/clickhouse_ruby/errors.rb +13 -1
- data/lib/clickhouse_ruby/result.rb +11 -16
- data/lib/clickhouse_ruby/retry_handler.rb +172 -0
- data/lib/clickhouse_ruby/streaming_result.rb +309 -0
- data/lib/clickhouse_ruby/types/array.rb +11 -64
- data/lib/clickhouse_ruby/types/base.rb +59 -0
- data/lib/clickhouse_ruby/types/boolean.rb +28 -25
- data/lib/clickhouse_ruby/types/date_time.rb +10 -27
- data/lib/clickhouse_ruby/types/decimal.rb +173 -0
- data/lib/clickhouse_ruby/types/enum.rb +262 -0
- data/lib/clickhouse_ruby/types/float.rb +14 -28
- data/lib/clickhouse_ruby/types/integer.rb +21 -43
- data/lib/clickhouse_ruby/types/low_cardinality.rb +1 -1
- data/lib/clickhouse_ruby/types/map.rb +21 -36
- data/lib/clickhouse_ruby/types/null_safe.rb +81 -0
- data/lib/clickhouse_ruby/types/nullable.rb +2 -2
- data/lib/clickhouse_ruby/types/parser.rb +28 -18
- data/lib/clickhouse_ruby/types/registry.rb +40 -29
- data/lib/clickhouse_ruby/types/string.rb +9 -13
- data/lib/clickhouse_ruby/types/string_parser.rb +135 -0
- data/lib/clickhouse_ruby/types/tuple.rb +11 -68
- data/lib/clickhouse_ruby/types/uuid.rb +15 -22
- data/lib/clickhouse_ruby/types.rb +19 -15
- data/lib/clickhouse_ruby/version.rb +1 -1
- data/lib/clickhouse_ruby.rb +11 -11
- metadata +41 -6
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module ClickhouseRuby
|
|
6
|
+
# Implements retry logic with exponential backoff and jitter
|
|
7
|
+
#
|
|
8
|
+
# This class handles transient failures in ClickHouse connections by
|
|
9
|
+
# retrying with an exponential backoff strategy and optional jitter.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# handler = RetryHandler.new(max_attempts: 3)
|
|
13
|
+
# result = handler.with_retry do
|
|
14
|
+
# client.execute('SELECT * FROM users')
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example With idempotency flag
|
|
18
|
+
# handler.with_retry(idempotent: false) do |query_id|
|
|
19
|
+
# client.insert('events', data, settings: { query_id: query_id })
|
|
20
|
+
# end
|
|
21
|
+
class RetryHandler
|
|
22
|
+
# Errors that should trigger a retry
|
|
23
|
+
RETRIABLE_ERRORS = [
|
|
24
|
+
ConnectionError,
|
|
25
|
+
ConnectionTimeout,
|
|
26
|
+
ConnectionNotEstablished,
|
|
27
|
+
PoolTimeout,
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# HTTP status codes that should trigger a retry
|
|
31
|
+
RETRIABLE_HTTP_CODES = %w[500 502 503 504 429].freeze
|
|
32
|
+
|
|
33
|
+
# @return [Integer] maximum number of attempts
|
|
34
|
+
attr_reader :max_attempts
|
|
35
|
+
|
|
36
|
+
# @return [Float] initial backoff delay in seconds
|
|
37
|
+
attr_reader :initial_backoff
|
|
38
|
+
|
|
39
|
+
# @return [Float] maximum backoff delay in seconds
|
|
40
|
+
attr_reader :max_backoff
|
|
41
|
+
|
|
42
|
+
# @return [Float] exponential backoff multiplier
|
|
43
|
+
attr_reader :multiplier
|
|
44
|
+
|
|
45
|
+
# @return [Symbol] jitter strategy (:full, :equal, or :none)
|
|
46
|
+
attr_reader :jitter
|
|
47
|
+
|
|
48
|
+
# Creates a new RetryHandler
|
|
49
|
+
#
|
|
50
|
+
# @param max_attempts [Integer] maximum retry attempts (default: 3)
|
|
51
|
+
# @param initial_backoff [Float] initial backoff in seconds (default: 1.0)
|
|
52
|
+
# @param max_backoff [Float] maximum backoff in seconds (default: 120.0)
|
|
53
|
+
# @param multiplier [Float] backoff multiplier (default: 1.6)
|
|
54
|
+
# @param jitter [Symbol] jitter strategy (default: :equal)
|
|
55
|
+
def initialize(
|
|
56
|
+
max_attempts: 3,
|
|
57
|
+
initial_backoff: 1.0,
|
|
58
|
+
max_backoff: 120.0,
|
|
59
|
+
multiplier: 1.6,
|
|
60
|
+
jitter: :equal
|
|
61
|
+
)
|
|
62
|
+
@max_attempts = max_attempts
|
|
63
|
+
@initial_backoff = initial_backoff
|
|
64
|
+
@max_backoff = max_backoff
|
|
65
|
+
@multiplier = multiplier
|
|
66
|
+
@jitter = jitter
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Executes a block with retry logic
|
|
70
|
+
#
|
|
71
|
+
# Yields to the block with an optional query_id. If the block raises
|
|
72
|
+
# a retriable error, retries with exponential backoff up to max_attempts.
|
|
73
|
+
# Non-retriable errors are re-raised immediately.
|
|
74
|
+
#
|
|
75
|
+
# @param idempotent [Boolean] whether the operation is idempotent (default: true)
|
|
76
|
+
# @param query_id [String, nil] query ID for deduplication (optional)
|
|
77
|
+
# @yieldparam query_id [String] generated or provided query_id
|
|
78
|
+
# @return [Object] return value from the block
|
|
79
|
+
# @raise [Error] if all retries are exhausted or error is non-retriable
|
|
80
|
+
#
|
|
81
|
+
# @example Idempotent operation (SELECT)
|
|
82
|
+
# handler.with_retry { client.execute('SELECT * FROM users') }
|
|
83
|
+
#
|
|
84
|
+
# @example Non-idempotent operation (INSERT)
|
|
85
|
+
# handler.with_retry(idempotent: false) do |qid|
|
|
86
|
+
# client.insert('events', data, settings: { query_id: qid })
|
|
87
|
+
# end
|
|
88
|
+
def with_retry(idempotent: true, query_id: nil)
|
|
89
|
+
attempts = 0
|
|
90
|
+
generated_query_id = query_id || SecureRandom.uuid
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
attempts += 1
|
|
94
|
+
yield(generated_query_id)
|
|
95
|
+
rescue *RETRIABLE_ERRORS => e
|
|
96
|
+
handle_retry(attempts, e, idempotent)
|
|
97
|
+
retry
|
|
98
|
+
rescue QueryError => e
|
|
99
|
+
# Check if HTTP code is retriable (server error or rate limit)
|
|
100
|
+
if retriable_http_error?(e)
|
|
101
|
+
handle_retry(attempts, e, idempotent)
|
|
102
|
+
retry
|
|
103
|
+
end
|
|
104
|
+
raise
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Checks if an error is retriable
|
|
109
|
+
#
|
|
110
|
+
# @param error [Exception] the error to check
|
|
111
|
+
# @return [Boolean] true if the error should trigger a retry
|
|
112
|
+
def retriable?(error)
|
|
113
|
+
RETRIABLE_ERRORS.any? { |klass| error.is_a?(klass) } ||
|
|
114
|
+
retriable_http_error?(error)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Handles retry logic for an attempt
|
|
120
|
+
#
|
|
121
|
+
# @param attempts [Integer] number of attempts so far
|
|
122
|
+
# @param error [Exception] the error that occurred
|
|
123
|
+
# @param idempotent [Boolean] whether operation is idempotent
|
|
124
|
+
# @raise [Exception] if max attempts reached
|
|
125
|
+
# @return [void]
|
|
126
|
+
def handle_retry(attempts, error, idempotent)
|
|
127
|
+
raise error if attempts >= @max_attempts
|
|
128
|
+
|
|
129
|
+
warn "Retrying non-idempotent operation - possible duplicates" unless idempotent
|
|
130
|
+
|
|
131
|
+
delay = calculate_delay(attempts)
|
|
132
|
+
sleep(delay)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Calculates backoff delay with jitter
|
|
136
|
+
#
|
|
137
|
+
# Implements exponential backoff:
|
|
138
|
+
# base = initial_backoff * (multiplier ^ (attempt - 1))
|
|
139
|
+
# capped = min(base, max_backoff)
|
|
140
|
+
#
|
|
141
|
+
# Then applies jitter strategy:
|
|
142
|
+
# - :full - random(0, capped)
|
|
143
|
+
# - :equal - capped/2 + random(0, capped/2)
|
|
144
|
+
# - :none - capped (no jitter)
|
|
145
|
+
#
|
|
146
|
+
# @param attempt [Integer] the attempt number (1-based)
|
|
147
|
+
# @return [Float] delay in seconds
|
|
148
|
+
def calculate_delay(attempt)
|
|
149
|
+
base = @initial_backoff * (@multiplier**(attempt - 1))
|
|
150
|
+
capped = [base, @max_backoff].min
|
|
151
|
+
|
|
152
|
+
case @jitter
|
|
153
|
+
when :full
|
|
154
|
+
rand * capped
|
|
155
|
+
when :none
|
|
156
|
+
capped
|
|
157
|
+
else
|
|
158
|
+
# Default to equal jitter (both :equal and unknown values)
|
|
159
|
+
(capped / 2.0) + (rand * (capped / 2.0))
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Checks if an error with HTTP status is retriable
|
|
164
|
+
#
|
|
165
|
+
# @param error [Exception] the error to check
|
|
166
|
+
# @return [Boolean] true if HTTP status indicates retriable error
|
|
167
|
+
def retriable_http_error?(error)
|
|
168
|
+
error.respond_to?(:http_status) &&
|
|
169
|
+
RETRIABLE_HTTP_CODES.include?(error.http_status.to_s)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "zlib"
|
|
7
|
+
|
|
8
|
+
module ClickhouseRuby
|
|
9
|
+
# Memory-efficient streaming result for large queries
|
|
10
|
+
#
|
|
11
|
+
# StreamingResult provides an Enumerable interface for processing ClickHouse
|
|
12
|
+
# query results without loading all rows into memory. Rows are parsed
|
|
13
|
+
# line-by-line as they arrive from the server.
|
|
14
|
+
#
|
|
15
|
+
# Features:
|
|
16
|
+
# - Enumerable interface for chainable operations
|
|
17
|
+
# - Lazy evaluation (no data loaded until iterated)
|
|
18
|
+
# - Support for gzip decompression
|
|
19
|
+
# - Progress callbacks
|
|
20
|
+
# - Batch processing
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage
|
|
23
|
+
# client.stream_execute('SELECT * FROM huge_table').each do |row|
|
|
24
|
+
# process(row)
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example Lazy enumeration with filtering
|
|
28
|
+
# result = client.stream_execute('SELECT * FROM huge_table')
|
|
29
|
+
# .lazy
|
|
30
|
+
# .select { |row| row['active'] == 1 }
|
|
31
|
+
# .take(100)
|
|
32
|
+
# .to_a
|
|
33
|
+
#
|
|
34
|
+
# @example Batch processing
|
|
35
|
+
# result.each_batch(size: 1000) do |batch|
|
|
36
|
+
# insert_into_cache(batch)
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
class StreamingResult
|
|
40
|
+
include Enumerable
|
|
41
|
+
|
|
42
|
+
# Creates a new streaming result
|
|
43
|
+
#
|
|
44
|
+
# @param connection [Connection] the ClickHouse connection
|
|
45
|
+
# @param sql [String] the SQL query to execute
|
|
46
|
+
# @param format [String] response format (default: JSONEachRow)
|
|
47
|
+
# @param compression [String, nil] compression algorithm ('gzip' or nil)
|
|
48
|
+
def initialize(connection, sql, format: "JSONEachRow", compression: nil)
|
|
49
|
+
@connection = connection
|
|
50
|
+
@sql = sql
|
|
51
|
+
@format = format
|
|
52
|
+
@compression = compression
|
|
53
|
+
@progress_callback = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sets a callback for progress updates
|
|
57
|
+
#
|
|
58
|
+
# ClickHouse sends progress headers during execution:
|
|
59
|
+
# X-ClickHouse-Progress: {"read_rows":"1000","read_bytes":"50000"}
|
|
60
|
+
#
|
|
61
|
+
# @yield [Hash] progress data
|
|
62
|
+
# @return [self] for method chaining
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# result.on_progress do |progress|
|
|
66
|
+
# puts "Processed #{progress['read_rows']} rows"
|
|
67
|
+
# end.each { |row| ... }
|
|
68
|
+
def on_progress(&block)
|
|
69
|
+
@progress_callback = block
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Iterates over each row in the result
|
|
74
|
+
#
|
|
75
|
+
# Returns an Enumerator if no block is given, allowing for lazy evaluation.
|
|
76
|
+
#
|
|
77
|
+
# @yield [Hash] each row as a parsed JSON object
|
|
78
|
+
# @return [Enumerator] if no block given, otherwise nil
|
|
79
|
+
#
|
|
80
|
+
# @example With block
|
|
81
|
+
# result.each { |row| puts row['id'] }
|
|
82
|
+
#
|
|
83
|
+
# @example Returns enumerator without block
|
|
84
|
+
# enumerator = result.each
|
|
85
|
+
def each(&block) # rubocop:disable Naming/BlockForwarding
|
|
86
|
+
return enum_for(__method__) unless block_given?
|
|
87
|
+
|
|
88
|
+
stream_query(&block) # rubocop:disable Naming/BlockForwarding
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Iterates over rows in batches
|
|
92
|
+
#
|
|
93
|
+
# Useful for batch processing (e.g., bulk database operations).
|
|
94
|
+
# The final batch may be smaller than the specified size.
|
|
95
|
+
#
|
|
96
|
+
# @param size [Integer] batch size (default: 1000)
|
|
97
|
+
# @yield [Array<Hash>] each batch of rows
|
|
98
|
+
# @return [Enumerator] if no block given, otherwise nil
|
|
99
|
+
#
|
|
100
|
+
# @example
|
|
101
|
+
# result.each_batch(size: 500) do |batch|
|
|
102
|
+
# insert_batch(batch)
|
|
103
|
+
# end
|
|
104
|
+
def each_batch(size: 1000)
|
|
105
|
+
return enum_for(__method__, size: size) unless block_given?
|
|
106
|
+
|
|
107
|
+
batch = []
|
|
108
|
+
each do |row|
|
|
109
|
+
batch << row
|
|
110
|
+
if batch.size >= size
|
|
111
|
+
yield batch
|
|
112
|
+
batch = []
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
yield batch if batch.any?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Executes the streaming query
|
|
121
|
+
#
|
|
122
|
+
# @yield [Hash] each parsed row
|
|
123
|
+
# @return [void]
|
|
124
|
+
def stream_query(&block) # rubocop:disable Naming/BlockForwarding
|
|
125
|
+
uri = build_uri
|
|
126
|
+
request = build_request(uri)
|
|
127
|
+
|
|
128
|
+
Net::HTTP.start(
|
|
129
|
+
uri.host,
|
|
130
|
+
uri.port,
|
|
131
|
+
use_ssl: @connection.use_ssl,
|
|
132
|
+
ssl_version: :TLSv1_2, # rubocop:disable Naming/VariableNumber
|
|
133
|
+
) do |http|
|
|
134
|
+
http.request(request) do |response|
|
|
135
|
+
check_response_status(response)
|
|
136
|
+
handle_progress(response)
|
|
137
|
+
|
|
138
|
+
parse_streaming_body(response, &block) # rubocop:disable Naming/BlockForwarding
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Builds the request URI
|
|
144
|
+
#
|
|
145
|
+
# @return [URI] the request URI with query parameters
|
|
146
|
+
def build_uri
|
|
147
|
+
uri = URI("http#{"s" if @connection.use_ssl}://#{@connection.host}:#{@connection.port}/")
|
|
148
|
+
params = {
|
|
149
|
+
"database" => @connection.database,
|
|
150
|
+
"query" => "#{@sql} FORMAT #{@format}",
|
|
151
|
+
}
|
|
152
|
+
params["enable_http_compression"] = "1" if @compression
|
|
153
|
+
uri.query = URI.encode_www_form(params)
|
|
154
|
+
uri
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Builds the HTTP request
|
|
158
|
+
#
|
|
159
|
+
# @param uri [URI] the request URI
|
|
160
|
+
# @return [Net::HTTP::Get] the HTTP request
|
|
161
|
+
def build_request(uri)
|
|
162
|
+
request = Net::HTTP::Get.new(uri)
|
|
163
|
+
request["Accept-Encoding"] = "gzip" if @compression
|
|
164
|
+
request
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Checks the HTTP response status
|
|
168
|
+
#
|
|
169
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
170
|
+
# @return [void]
|
|
171
|
+
# @raise [QueryError] if status is not 200
|
|
172
|
+
def check_response_status(response)
|
|
173
|
+
return if response.code == "200"
|
|
174
|
+
|
|
175
|
+
body = response.body || ""
|
|
176
|
+
raise_clickhouse_error(response, body)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Handles progress header if callback is set
|
|
180
|
+
#
|
|
181
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
182
|
+
# @return [void]
|
|
183
|
+
def handle_progress(response)
|
|
184
|
+
return unless @progress_callback
|
|
185
|
+
|
|
186
|
+
progress = response["X-ClickHouse-Progress"]
|
|
187
|
+
return unless progress
|
|
188
|
+
|
|
189
|
+
@progress_callback.call(JSON.parse(progress))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Parses the streaming response body
|
|
193
|
+
#
|
|
194
|
+
# Handles:
|
|
195
|
+
# - Chunked transfer encoding
|
|
196
|
+
# - Incomplete line buffering
|
|
197
|
+
# - Gzip decompression if enabled
|
|
198
|
+
#
|
|
199
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
200
|
+
# @yield [Hash] each parsed row
|
|
201
|
+
# @return [void]
|
|
202
|
+
def parse_streaming_body(response) # rubocop:disable Metrics/CyclomaticComplexity
|
|
203
|
+
decompressor = create_decompressor(response)
|
|
204
|
+
buffer = ""
|
|
205
|
+
|
|
206
|
+
response.read_body do |chunk|
|
|
207
|
+
data = decompressor ? decompressor.inflate(chunk) : chunk
|
|
208
|
+
buffer += data
|
|
209
|
+
|
|
210
|
+
# Process complete lines
|
|
211
|
+
while buffer.include?("\n")
|
|
212
|
+
line, buffer = buffer.split("\n", 2)
|
|
213
|
+
next if line.empty?
|
|
214
|
+
|
|
215
|
+
row = parse_row(line)
|
|
216
|
+
yield row if row
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Finalize decompression
|
|
221
|
+
if decompressor
|
|
222
|
+
buffer += decompressor.finish
|
|
223
|
+
decompressor.close
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Process remaining buffer (last line may not end with \n)
|
|
227
|
+
return if buffer.strip.empty?
|
|
228
|
+
|
|
229
|
+
row = parse_row(buffer)
|
|
230
|
+
yield row if row
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Creates a decompressor based on Content-Encoding header
|
|
234
|
+
#
|
|
235
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
236
|
+
# @return [Zlib::Inflate, nil] decompressor or nil
|
|
237
|
+
def create_decompressor(response)
|
|
238
|
+
case response["Content-Encoding"]
|
|
239
|
+
when "gzip"
|
|
240
|
+
Zlib::Inflate.new(16 + Zlib::MAX_WBITS)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Parses a single row from JSON
|
|
245
|
+
#
|
|
246
|
+
# @param line [String] JSON line
|
|
247
|
+
# @return [Hash, nil] parsed row or nil
|
|
248
|
+
# @raise [QueryError] if line contains error
|
|
249
|
+
def parse_row(line)
|
|
250
|
+
data = JSON.parse(line)
|
|
251
|
+
|
|
252
|
+
# Check for error in stream
|
|
253
|
+
if data["exception"]
|
|
254
|
+
raise QueryError.new(
|
|
255
|
+
data["exception"]["message"],
|
|
256
|
+
code: data["exception"]["code"],
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
data
|
|
261
|
+
rescue JSON::ParserError => e
|
|
262
|
+
raise QueryError.new(
|
|
263
|
+
"Failed to parse streaming response: #{e.message}",
|
|
264
|
+
original_error: e,
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Raises a ClickHouse error
|
|
269
|
+
#
|
|
270
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
271
|
+
# @param body [String] response body
|
|
272
|
+
# @return [void]
|
|
273
|
+
# @raise [QueryError] always raises
|
|
274
|
+
def raise_clickhouse_error(response, body)
|
|
275
|
+
code = extract_error_code(body)
|
|
276
|
+
message = extract_error_message(body)
|
|
277
|
+
|
|
278
|
+
error_class = ClickhouseRuby.error_class_for_code(code)
|
|
279
|
+
|
|
280
|
+
raise error_class.new(
|
|
281
|
+
message,
|
|
282
|
+
code: code,
|
|
283
|
+
http_status: response.code,
|
|
284
|
+
sql: @sql,
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Extracts ClickHouse error code from response body
|
|
289
|
+
#
|
|
290
|
+
# @param body [String] response body
|
|
291
|
+
# @return [Integer, nil] error code or nil
|
|
292
|
+
def extract_error_code(body)
|
|
293
|
+
match = body.match(/Code:\s*(\d+)/)
|
|
294
|
+
match ? match[1].to_i : nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Extracts error message from response body
|
|
298
|
+
#
|
|
299
|
+
# @param body [String] response body
|
|
300
|
+
# @return [String] error message
|
|
301
|
+
def extract_error_message(body)
|
|
302
|
+
if body =~ /DB::Exception:\s*(.+?)(?:\s*\(version|$)/m
|
|
303
|
+
::Regexp.last_match(1).strip
|
|
304
|
+
else
|
|
305
|
+
body.strip.empty? ? "Unknown ClickHouse error" : body.strip
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -20,7 +20,7 @@ module ClickhouseRuby
|
|
|
20
20
|
# @param element_type [Base] the element type
|
|
21
21
|
def initialize(name, element_type: nil)
|
|
22
22
|
super(name)
|
|
23
|
-
@element_type = element_type || Base.new(
|
|
23
|
+
@element_type = element_type || Base.new("String")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Converts a Ruby value to an array
|
|
@@ -37,12 +37,7 @@ module ClickhouseRuby
|
|
|
37
37
|
when ::String
|
|
38
38
|
parse_array_string(value)
|
|
39
39
|
else
|
|
40
|
-
|
|
41
|
-
"Cannot cast #{value.class} to Array",
|
|
42
|
-
from_type: value.class.name,
|
|
43
|
-
to_type: to_s,
|
|
44
|
-
value: value
|
|
45
|
-
)
|
|
40
|
+
raise_cast_error(value, "Cannot cast #{value.class} to Array")
|
|
46
41
|
end
|
|
47
42
|
|
|
48
43
|
arr.map { |v| @element_type.cast(v) }
|
|
@@ -72,10 +67,10 @@ module ClickhouseRuby
|
|
|
72
67
|
# @param value [Array, nil] the value to serialize
|
|
73
68
|
# @return [String] the SQL literal
|
|
74
69
|
def serialize(value)
|
|
75
|
-
return
|
|
70
|
+
return "NULL" if value.nil?
|
|
76
71
|
|
|
77
72
|
elements = value.map { |v| @element_type.serialize(v) }
|
|
78
|
-
"[#{elements.join(
|
|
73
|
+
"[#{elements.join(", ")}]"
|
|
79
74
|
end
|
|
80
75
|
|
|
81
76
|
# Returns the full type string including element type
|
|
@@ -95,17 +90,10 @@ module ClickhouseRuby
|
|
|
95
90
|
stripped = value.strip
|
|
96
91
|
|
|
97
92
|
# Handle empty array
|
|
98
|
-
return [] if stripped ==
|
|
93
|
+
return [] if stripped == "[]"
|
|
99
94
|
|
|
100
95
|
# Remove outer brackets
|
|
101
|
-
unless stripped.start_with?(
|
|
102
|
-
raise TypeCastError.new(
|
|
103
|
-
"Invalid array format: '#{value}'",
|
|
104
|
-
from_type: 'String',
|
|
105
|
-
to_type: to_s,
|
|
106
|
-
value: value
|
|
107
|
-
)
|
|
108
|
-
end
|
|
96
|
+
raise_format_error(value, "array") unless stripped.start_with?("[") && stripped.end_with?("]")
|
|
109
97
|
|
|
110
98
|
inner = stripped[1...-1]
|
|
111
99
|
return [] if inner.strip.empty?
|
|
@@ -119,48 +107,9 @@ module ClickhouseRuby
|
|
|
119
107
|
# @param str [String] the inner array string
|
|
120
108
|
# @return [Array] the parsed elements
|
|
121
109
|
def parse_elements(str)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
depth = 0
|
|
125
|
-
in_string = false
|
|
126
|
-
escape_next = false
|
|
127
|
-
|
|
128
|
-
str.each_char do |char|
|
|
129
|
-
if escape_next
|
|
130
|
-
current += char
|
|
131
|
-
escape_next = false
|
|
132
|
-
next
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
case char
|
|
136
|
-
when '\\'
|
|
137
|
-
escape_next = true
|
|
138
|
-
current += char
|
|
139
|
-
when "'"
|
|
140
|
-
in_string = !in_string
|
|
141
|
-
current += char
|
|
142
|
-
when '[', '('
|
|
143
|
-
depth += 1 unless in_string
|
|
144
|
-
current += char
|
|
145
|
-
when ']', ')'
|
|
146
|
-
depth -= 1 unless in_string
|
|
147
|
-
current += char
|
|
148
|
-
when ','
|
|
149
|
-
if depth.zero? && !in_string
|
|
150
|
-
elements << parse_element(current.strip)
|
|
151
|
-
current = ''
|
|
152
|
-
else
|
|
153
|
-
current += char
|
|
154
|
-
end
|
|
155
|
-
else
|
|
156
|
-
current += char
|
|
157
|
-
end
|
|
110
|
+
StringParser.parse_delimited(str, open_brackets: ["[", "("], close_brackets: ["]", ")"]).map do |el|
|
|
111
|
+
parse_element(el)
|
|
158
112
|
end
|
|
159
|
-
|
|
160
|
-
# Don't forget the last element
|
|
161
|
-
elements << parse_element(current.strip) unless current.strip.empty?
|
|
162
|
-
|
|
163
|
-
elements
|
|
164
113
|
end
|
|
165
114
|
|
|
166
115
|
# Parses a single element, removing quotes if necessary
|
|
@@ -168,12 +117,10 @@ module ClickhouseRuby
|
|
|
168
117
|
# @param str [String] the element string
|
|
169
118
|
# @return [Object] the parsed element
|
|
170
119
|
def parse_element(str)
|
|
171
|
-
#
|
|
120
|
+
# Unquote strings (e.g., "'[test]'" -> "[test]")
|
|
121
|
+
# Preserve unquoted values like nested arrays [1, 2] or numbers
|
|
172
122
|
if str.start_with?("'") && str.end_with?("'")
|
|
173
|
-
|
|
174
|
-
elsif str.start_with?('[')
|
|
175
|
-
# Nested array - let the element type handle it
|
|
176
|
-
str
|
|
123
|
+
StringParser.unquote(str)
|
|
177
124
|
else
|
|
178
125
|
str
|
|
179
126
|
end
|
|
@@ -72,6 +72,65 @@ module ClickhouseRuby
|
|
|
72
72
|
def hash
|
|
73
73
|
name.hash
|
|
74
74
|
end
|
|
75
|
+
|
|
76
|
+
protected
|
|
77
|
+
|
|
78
|
+
# Raises a TypeCastError for unsupported type conversion
|
|
79
|
+
#
|
|
80
|
+
# @param value [Object] the value that could not be cast
|
|
81
|
+
# @param message [String, nil] optional custom message
|
|
82
|
+
# @raise [TypeCastError] always
|
|
83
|
+
def raise_cast_error(value, message = nil)
|
|
84
|
+
msg = message || "Cannot cast #{value.class} to #{name}"
|
|
85
|
+
raise TypeCastError.new(
|
|
86
|
+
msg,
|
|
87
|
+
from_type: value.class.name,
|
|
88
|
+
to_type: name,
|
|
89
|
+
value: value,
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Raises a TypeCastError for invalid string format
|
|
94
|
+
#
|
|
95
|
+
# @param value [String] the invalid string
|
|
96
|
+
# @param format_name [String] description of expected format
|
|
97
|
+
# @raise [TypeCastError] always
|
|
98
|
+
def raise_format_error(value, format_name)
|
|
99
|
+
raise TypeCastError.new(
|
|
100
|
+
"Invalid #{format_name} format: '#{value}'",
|
|
101
|
+
from_type: "String",
|
|
102
|
+
to_type: name,
|
|
103
|
+
value: value,
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Raises a TypeCastError for empty string input
|
|
108
|
+
#
|
|
109
|
+
# @param value [String] the empty string
|
|
110
|
+
# @raise [TypeCastError] always
|
|
111
|
+
def raise_empty_string_error(value)
|
|
112
|
+
raise TypeCastError.new(
|
|
113
|
+
"Cannot cast empty string to #{name}",
|
|
114
|
+
from_type: "String",
|
|
115
|
+
to_type: name,
|
|
116
|
+
value: value,
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Raises a TypeCastError for value out of range
|
|
121
|
+
#
|
|
122
|
+
# @param value [Numeric] the out-of-range value
|
|
123
|
+
# @param min [Numeric] minimum allowed value
|
|
124
|
+
# @param max [Numeric] maximum allowed value
|
|
125
|
+
# @raise [TypeCastError] always
|
|
126
|
+
def raise_range_error(value, min, max)
|
|
127
|
+
raise TypeCastError.new(
|
|
128
|
+
"Value #{value} is out of range for #{name} (#{min}..#{max})",
|
|
129
|
+
from_type: value.class.name,
|
|
130
|
+
to_type: name,
|
|
131
|
+
value: value,
|
|
132
|
+
)
|
|
133
|
+
end
|
|
75
134
|
end
|
|
76
135
|
end
|
|
77
136
|
end
|