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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/README.md +165 -79
  4. data/lib/clickhouse_ruby/active_record/arel_visitor.rb +205 -76
  5. data/lib/clickhouse_ruby/active_record/connection_adapter.rb +103 -98
  6. data/lib/clickhouse_ruby/active_record/railtie.rb +20 -15
  7. data/lib/clickhouse_ruby/active_record/relation_extensions.rb +398 -0
  8. data/lib/clickhouse_ruby/active_record/schema_statements.rb +90 -104
  9. data/lib/clickhouse_ruby/active_record.rb +24 -10
  10. data/lib/clickhouse_ruby/client.rb +181 -74
  11. data/lib/clickhouse_ruby/configuration.rb +51 -10
  12. data/lib/clickhouse_ruby/connection.rb +180 -64
  13. data/lib/clickhouse_ruby/connection_pool.rb +25 -19
  14. data/lib/clickhouse_ruby/errors.rb +13 -1
  15. data/lib/clickhouse_ruby/result.rb +11 -16
  16. data/lib/clickhouse_ruby/retry_handler.rb +172 -0
  17. data/lib/clickhouse_ruby/streaming_result.rb +309 -0
  18. data/lib/clickhouse_ruby/types/array.rb +11 -64
  19. data/lib/clickhouse_ruby/types/base.rb +59 -0
  20. data/lib/clickhouse_ruby/types/boolean.rb +28 -25
  21. data/lib/clickhouse_ruby/types/date_time.rb +10 -27
  22. data/lib/clickhouse_ruby/types/decimal.rb +173 -0
  23. data/lib/clickhouse_ruby/types/enum.rb +262 -0
  24. data/lib/clickhouse_ruby/types/float.rb +14 -28
  25. data/lib/clickhouse_ruby/types/integer.rb +21 -43
  26. data/lib/clickhouse_ruby/types/low_cardinality.rb +1 -1
  27. data/lib/clickhouse_ruby/types/map.rb +21 -36
  28. data/lib/clickhouse_ruby/types/null_safe.rb +81 -0
  29. data/lib/clickhouse_ruby/types/nullable.rb +2 -2
  30. data/lib/clickhouse_ruby/types/parser.rb +28 -18
  31. data/lib/clickhouse_ruby/types/registry.rb +40 -29
  32. data/lib/clickhouse_ruby/types/string.rb +9 -13
  33. data/lib/clickhouse_ruby/types/string_parser.rb +135 -0
  34. data/lib/clickhouse_ruby/types/tuple.rb +11 -68
  35. data/lib/clickhouse_ruby/types/uuid.rb +15 -22
  36. data/lib/clickhouse_ruby/types.rb +19 -15
  37. data/lib/clickhouse_ruby/version.rb +1 -1
  38. data/lib/clickhouse_ruby.rb +11 -11
  39. 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('String')
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
- raise TypeCastError.new(
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 'NULL' if value.nil?
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?('[') && stripped.end_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
- elements = []
123
- current = ''
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
- # Remove surrounding quotes if present
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
- str[1...-1].gsub("\\'", "'")
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