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
@@ -27,12 +27,7 @@ module ClickhouseRuby
27
27
  when ::BigDecimal
28
28
  value.to_f
29
29
  else
30
- raise TypeCastError.new(
31
- "Cannot cast #{value.class} to #{name}",
32
- from_type: value.class.name,
33
- to_type: name,
34
- value: value
35
- )
30
+ raise_cast_error(value)
36
31
  end
37
32
  end
38
33
 
@@ -40,6 +35,7 @@ module ClickhouseRuby
40
35
  #
41
36
  # @param value [Object] the value from ClickHouse
42
37
  # @return [Float, nil] the float value
38
+ # @raise [TypeCastError] if the value cannot be converted
43
39
  def deserialize(value)
44
40
  return nil if value.nil?
45
41
 
@@ -48,8 +44,10 @@ module ClickhouseRuby
48
44
  value
49
45
  when ::String
50
46
  parse_string(value)
51
- else
47
+ when ::Integer, ::BigDecimal, ::Rational
52
48
  value.to_f
49
+ else
50
+ raise_cast_error(value, "Cannot deserialize #{value.class} to #{name}")
53
51
  end
54
52
  end
55
53
 
@@ -58,14 +56,14 @@ module ClickhouseRuby
58
56
  # @param value [Float, nil] the value to serialize
59
57
  # @return [String] the SQL literal
60
58
  def serialize(value)
61
- return 'NULL' if value.nil?
59
+ return "NULL" if value.nil?
62
60
 
63
61
  if value.nan?
64
- 'nan'
62
+ "nan"
65
63
  elsif value.infinite? == 1
66
- 'inf'
64
+ "inf"
67
65
  elsif value.infinite? == -1
68
- '-inf'
66
+ "-inf"
69
67
  else
70
68
  value.to_s
71
69
  end
@@ -83,32 +81,20 @@ module ClickhouseRuby
83
81
 
84
82
  # Handle special values
85
83
  case stripped
86
- when 'inf', '+inf', 'infinity', '+infinity'
84
+ when "inf", "+inf", "infinity", "+infinity"
87
85
  ::Float::INFINITY
88
- when '-inf', '-infinity'
86
+ when "-inf", "-infinity"
89
87
  -::Float::INFINITY
90
- when 'nan'
88
+ when "nan"
91
89
  ::Float::NAN
92
90
  else
93
91
  # Handle empty strings
94
- if stripped.empty?
95
- raise TypeCastError.new(
96
- "Cannot cast empty string to #{name}",
97
- from_type: 'String',
98
- to_type: name,
99
- value: value
100
- )
101
- end
92
+ raise_empty_string_error(value) if stripped.empty?
102
93
 
103
94
  Float(stripped)
104
95
  end
105
96
  rescue ArgumentError
106
- raise TypeCastError.new(
107
- "Cannot cast '#{value}' to #{name}",
108
- from_type: 'String',
109
- to_type: name,
110
- value: value
111
- )
97
+ raise_cast_error(value, "Cannot cast '#{value}' to #{name}")
112
98
  end
113
99
  end
114
100
  end
@@ -13,18 +13,18 @@ module ClickhouseRuby
13
13
  class Integer < Base
14
14
  # Size limits for each integer type
15
15
  LIMITS = {
16
- 'Int8' => { min: -128, max: 127 },
17
- 'Int16' => { min: -32_768, max: 32_767 },
18
- 'Int32' => { min: -2_147_483_648, max: 2_147_483_647 },
19
- 'Int64' => { min: -9_223_372_036_854_775_808, max: 9_223_372_036_854_775_807 },
20
- 'Int128' => { min: -(2**127), max: (2**127) - 1 },
21
- 'Int256' => { min: -(2**255), max: (2**255) - 1 },
22
- 'UInt8' => { min: 0, max: 255 },
23
- 'UInt16' => { min: 0, max: 65_535 },
24
- 'UInt32' => { min: 0, max: 4_294_967_295 },
25
- 'UInt64' => { min: 0, max: 18_446_744_073_709_551_615 },
26
- 'UInt128' => { min: 0, max: (2**128) - 1 },
27
- 'UInt256' => { min: 0, max: (2**256) - 1 }
16
+ "Int8" => { min: -128, max: 127 },
17
+ "Int16" => { min: -32_768, max: 32_767 },
18
+ "Int32" => { min: -2_147_483_648, max: 2_147_483_647 },
19
+ "Int64" => { min: -9_223_372_036_854_775_808, max: 9_223_372_036_854_775_807 },
20
+ "Int128" => { min: -(2**127), max: (2**127) - 1 },
21
+ "Int256" => { min: -(2**255), max: (2**255) - 1 },
22
+ "UInt8" => { min: 0, max: 255 },
23
+ "UInt16" => { min: 0, max: 65_535 },
24
+ "UInt32" => { min: 0, max: 4_294_967_295 },
25
+ "UInt64" => { min: 0, max: 18_446_744_073_709_551_615 },
26
+ "UInt128" => { min: 0, max: (2**128) - 1 },
27
+ "UInt256" => { min: 0, max: (2**256) - 1 },
28
28
  }.freeze
29
29
 
30
30
  # Converts a Ruby value to an integer
@@ -52,12 +52,7 @@ module ClickhouseRuby
52
52
  when false
53
53
  0
54
54
  else
55
- raise TypeCastError.new(
56
- "Cannot cast #{value.class} to #{name}",
57
- from_type: value.class.name,
58
- to_type: name,
59
- value: value
60
- )
55
+ raise_cast_error(value)
61
56
  end
62
57
  end
63
58
 
@@ -85,7 +80,7 @@ module ClickhouseRuby
85
80
  # @param value [Integer, nil] the value to serialize
86
81
  # @return [String] the SQL literal
87
82
  def serialize(value)
88
- return 'NULL' if value.nil?
83
+ return "NULL" if value.nil?
89
84
 
90
85
  value.to_s
91
86
  end
@@ -94,14 +89,14 @@ module ClickhouseRuby
94
89
  #
95
90
  # @return [Boolean] true if unsigned
96
91
  def unsigned?
97
- name.start_with?('U')
92
+ name.start_with?("U")
98
93
  end
99
94
 
100
95
  # Returns the bit size of this integer type
101
96
  #
102
97
  # @return [Integer] the bit size (8, 16, 32, 64, 128, or 256)
103
98
  def bit_size
104
- name.gsub(/[^0-9]/, '').to_i
99
+ name.gsub(/[^0-9]/, "").to_i
105
100
  end
106
101
 
107
102
  private
@@ -115,24 +110,12 @@ module ClickhouseRuby
115
110
  stripped = value.strip
116
111
 
117
112
  # Handle empty strings
118
- if stripped.empty?
119
- raise TypeCastError.new(
120
- "Cannot cast empty string to #{name}",
121
- from_type: 'String',
122
- to_type: name,
123
- value: value
124
- )
125
- end
113
+ raise_empty_string_error(value) if stripped.empty?
126
114
 
127
115
  # Use Integer() for strict parsing
128
116
  Integer(stripped)
129
117
  rescue ArgumentError
130
- raise TypeCastError.new(
131
- "Cannot cast '#{value}' to #{name}",
132
- from_type: 'String',
133
- to_type: name,
134
- value: value
135
- )
118
+ raise_cast_error(value, "Cannot cast '#{value}' to #{name}")
136
119
  end
137
120
 
138
121
  # Validates that a value is within the type's range
@@ -143,14 +126,9 @@ module ClickhouseRuby
143
126
  limits = LIMITS[name]
144
127
  return unless limits # Unknown type, skip validation
145
128
 
146
- if value < limits[:min] || value > limits[:max]
147
- raise TypeCastError.new(
148
- "Value #{value} is out of range for #{name} (#{limits[:min]}..#{limits[:max]})",
149
- from_type: value.class.name,
150
- to_type: name,
151
- value: value
152
- )
153
- end
129
+ return unless value < limits[:min] || value > limits[:max]
130
+
131
+ raise_range_error(value, limits[:min], limits[:max])
154
132
  end
155
133
  end
156
134
  end
@@ -20,7 +20,7 @@ module ClickhouseRuby
20
20
  # @param element_type [Base] the wrapped 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 using the wrapped type
@@ -23,9 +23,9 @@ module ClickhouseRuby
23
23
  # @param arg_types [Array<Base>] array of [key_type, value_type]
24
24
  def initialize(name, arg_types: nil)
25
25
  super(name)
26
- arg_types ||= [Base.new('String'), Base.new('String')]
26
+ arg_types ||= [Base.new("String"), Base.new("String")]
27
27
  @key_type = arg_types[0]
28
- @value_type = arg_types[1] || Base.new('String')
28
+ @value_type = arg_types[1] || Base.new("String")
29
29
  end
30
30
 
31
31
  # Converts a Ruby value to a map (Hash)
@@ -42,16 +42,11 @@ module ClickhouseRuby
42
42
  when ::String
43
43
  parse_map_string(value)
44
44
  else
45
- raise TypeCastError.new(
46
- "Cannot cast #{value.class} to Map",
47
- from_type: value.class.name,
48
- to_type: to_s,
49
- value: value
50
- )
45
+ raise_cast_error(value, "Cannot cast #{value.class} to Map")
51
46
  end
52
47
 
53
48
  hash.transform_keys { |k| @key_type.cast(k) }
54
- .transform_values { |v| @value_type.cast(v) }
49
+ .transform_values { |v| @value_type.cast(v) }
55
50
  end
56
51
 
57
52
  # Converts a value from ClickHouse to a Ruby Hash
@@ -71,7 +66,7 @@ module ClickhouseRuby
71
66
  end
72
67
 
73
68
  hash.transform_keys { |k| @key_type.deserialize(k) }
74
- .transform_values { |v| @value_type.deserialize(v) }
69
+ .transform_values { |v| @value_type.deserialize(v) }
75
70
  end
76
71
 
77
72
  # Converts a hash to SQL literal
@@ -79,13 +74,13 @@ module ClickhouseRuby
79
74
  # @param value [Hash, nil] the value to serialize
80
75
  # @return [String] the SQL literal
81
76
  def serialize(value)
82
- return 'NULL' if value.nil?
77
+ return "NULL" if value.nil?
83
78
 
84
79
  pairs = value.map do |k, v|
85
80
  "#{@key_type.serialize(k)}: #{@value_type.serialize(v)}"
86
81
  end
87
82
 
88
- "{#{pairs.join(', ')}}"
83
+ "{#{pairs.join(", ")}}"
89
84
  end
90
85
 
91
86
  # Returns the full type string including key and value types
@@ -105,16 +100,11 @@ module ClickhouseRuby
105
100
  stripped = value.strip
106
101
 
107
102
  # Handle empty map
108
- return {} if stripped == '{}'
103
+ return {} if stripped == "{}"
109
104
 
110
105
  # Remove outer braces
111
- unless stripped.start_with?('{') && stripped.end_with?('}')
112
- raise TypeCastError.new(
113
- "Invalid map format: '#{value}'",
114
- from_type: 'String',
115
- to_type: to_s,
116
- value: value
117
- )
106
+ unless stripped.start_with?("{") && stripped.end_with?("}")
107
+ raise_format_error(value, "map")
118
108
  end
119
109
 
120
110
  inner = stripped[1...-1]
@@ -130,7 +120,7 @@ module ClickhouseRuby
130
120
  # @return [Hash] the parsed pairs
131
121
  def parse_pairs(str)
132
122
  result = {}
133
- current = ''
123
+ current = ""
134
124
  depth = 0
135
125
  in_string = false
136
126
  escape_next = false
@@ -143,23 +133,23 @@ module ClickhouseRuby
143
133
  end
144
134
 
145
135
  case char
146
- when '\\'
136
+ when "\\"
147
137
  escape_next = true
148
138
  current += char
149
139
  when "'"
150
140
  in_string = !in_string
151
141
  current += char
152
- when '{', '[', '('
142
+ when "{", "[", "("
153
143
  depth += 1 unless in_string
154
144
  current += char
155
- when '}', ']', ')'
145
+ when "}", "]", ")"
156
146
  depth -= 1 unless in_string
157
147
  current += char
158
- when ','
148
+ when ","
159
149
  if depth.zero? && !in_string
160
150
  key, value = parse_pair(current.strip)
161
151
  result[key] = value
162
- current = ''
152
+ current = ""
163
153
  else
164
154
  current += char
165
155
  end
@@ -183,15 +173,10 @@ module ClickhouseRuby
183
173
  # @return [Array] [key, value]
184
174
  def parse_pair(str)
185
175
  # Find the colon separator (not inside quotes or nested structures)
186
- colon_idx = find_separator(str, ':')
176
+ colon_idx = find_separator(str, ":")
187
177
 
188
178
  if colon_idx.nil?
189
- raise TypeCastError.new(
190
- "Invalid map pair format: '#{str}'",
191
- from_type: 'String',
192
- to_type: to_s,
193
- value: str
194
- )
179
+ raise_format_error(str, "map pair")
195
180
  end
196
181
 
197
182
  key = parse_value(str[0...colon_idx].strip)
@@ -217,13 +202,13 @@ module ClickhouseRuby
217
202
  end
218
203
 
219
204
  case char
220
- when '\\'
205
+ when "\\"
221
206
  escape_next = true
222
207
  when "'"
223
208
  in_string = !in_string
224
- when '{', '[', '('
209
+ when "{", "[", "("
225
210
  depth += 1 unless in_string
226
- when '}', ']', ')'
211
+ when "}", "]", ")"
227
212
  depth -= 1 unless in_string
228
213
  when sep
229
214
  return idx if depth.zero? && !in_string
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ module Types
5
+ # Provides automatic null handling for type classes
6
+ #
7
+ # When included, wraps cast, deserialize, and serialize methods
8
+ # to handle nil values automatically. Subclasses implement
9
+ # cast_value, deserialize_value, and serialize_value instead.
10
+ #
11
+ # @example
12
+ # class Integer < Base
13
+ # include NullSafe
14
+ #
15
+ # protected
16
+ #
17
+ # def cast_value(value)
18
+ # # value is guaranteed non-nil here
19
+ # value.to_i
20
+ # end
21
+ # end
22
+ #
23
+ module NullSafe
24
+ # Converts a Ruby value, returning nil for nil input
25
+ #
26
+ # @param value [Object] the value to cast
27
+ # @return [Object, nil] the cast value or nil
28
+ def cast(value)
29
+ return nil if value.nil?
30
+
31
+ cast_value(value)
32
+ end
33
+
34
+ # Deserializes a value from ClickHouse, returning nil for nil input
35
+ #
36
+ # @param value [Object] the value from ClickHouse
37
+ # @return [Object, nil] the deserialized value or nil
38
+ def deserialize(value)
39
+ return nil if value.nil?
40
+
41
+ deserialize_value(value)
42
+ end
43
+
44
+ # Serializes a value for ClickHouse, returning "NULL" for nil input
45
+ #
46
+ # @param value [Object] the value to serialize
47
+ # @return [String] the SQL literal
48
+ def serialize(value)
49
+ return "NULL" if value.nil?
50
+
51
+ serialize_value(value)
52
+ end
53
+
54
+ protected
55
+
56
+ # Override in subclass - actual casting logic (value guaranteed non-nil)
57
+ #
58
+ # @param value [Object] the non-nil value to cast
59
+ # @return [Object] the cast value
60
+ def cast_value(value)
61
+ value
62
+ end
63
+
64
+ # Override in subclass - actual deserialization logic (value guaranteed non-nil)
65
+ #
66
+ # @param value [Object] the non-nil value to deserialize
67
+ # @return [Object] the deserialized value
68
+ def deserialize_value(value)
69
+ value
70
+ end
71
+
72
+ # Override in subclass - actual serialization logic (value guaranteed non-nil)
73
+ #
74
+ # @param value [Object] the non-nil value to serialize
75
+ # @return [String] the SQL literal
76
+ def serialize_value(value)
77
+ value.to_s
78
+ end
79
+ end
80
+ end
81
+ end
@@ -21,7 +21,7 @@ module ClickhouseRuby
21
21
  # @param element_type [Base] the wrapped type
22
22
  def initialize(name, element_type: nil)
23
23
  super(name)
24
- @element_type = element_type || Base.new('String')
24
+ @element_type = element_type || Base.new("String")
25
25
  end
26
26
 
27
27
  # Converts a Ruby value, allowing nil
@@ -50,7 +50,7 @@ module ClickhouseRuby
50
50
  # @param value [Object, nil] the value to serialize
51
51
  # @return [String] the SQL literal
52
52
  def serialize(value)
53
- return 'NULL' if value.nil?
53
+ return "NULL" if value.nil?
54
54
 
55
55
  @element_type.serialize(value)
56
56
  end
@@ -58,12 +58,12 @@ module ClickhouseRuby
58
58
  # @return [Hash] the parsed AST with :type and optional :args keys
59
59
  # @raise [ParseError] if the type string is invalid
60
60
  def parse(type_string)
61
- raise ParseError.new('Type string cannot be nil') if type_string.nil?
61
+ raise ParseError, "Type string cannot be nil" if type_string.nil?
62
62
 
63
63
  @input = type_string.strip
64
64
  @pos = 0
65
65
 
66
- raise ParseError.new('Type string cannot be empty', input: type_string) if @input.empty?
66
+ raise ParseError.new("Type string cannot be empty", input: type_string) if @input.empty?
67
67
 
68
68
  result = parse_type
69
69
  skip_whitespace
@@ -84,6 +84,7 @@ module ClickhouseRuby
84
84
  # - Type names: String, UInt64
85
85
  # - Numeric literals: 3 (precision), 9 (scale)
86
86
  # - String literals: 'UTC' (timezone)
87
+ # - Enum entries: 'active' = 1 (for Enum8/Enum16)
87
88
  #
88
89
  # @return [Hash] the parsed type/value
89
90
  def parse_type
@@ -95,19 +96,32 @@ module ClickhouseRuby
95
96
  return { type: value }
96
97
  end
97
98
 
98
- # Handle string literals (e.g., DateTime64(3, 'UTC'))
99
+ # Handle string literals (e.g., DateTime64(3, 'UTC') or Enum8('active' = 1))
99
100
  if peek == "'"
100
101
  value = parse_string_literal
102
+ skip_whitespace
103
+ # Handle Enum value assignment: 'name' = value (or 'name' = -1)
104
+ if peek == "="
105
+ consume("=")
106
+ skip_whitespace
107
+ # Skip optional negative sign
108
+ if peek == "-"
109
+ @pos += 1
110
+ skip_whitespace
111
+ end
112
+ # Skip the numeric value
113
+ parse_numeric if numeric_char?(peek)
114
+ end
101
115
  return { type: value }
102
116
  end
103
117
 
104
118
  name = parse_identifier
105
119
 
106
120
  skip_whitespace
107
- if peek == '('
108
- consume('(')
121
+ if peek == "("
122
+ consume("(")
109
123
  args = parse_type_list
110
- consume(')')
124
+ consume(")")
111
125
  { type: name, args: args }
112
126
  else
113
127
  { type: name }
@@ -122,12 +136,12 @@ module ClickhouseRuby
122
136
  skip_whitespace
123
137
 
124
138
  # Handle empty argument list
125
- return types if peek == ')'
139
+ return types if peek == ")"
126
140
 
127
141
  types << parse_type
128
142
 
129
- while peek == ','
130
- consume(',')
143
+ while peek == ","
144
+ consume(",")
131
145
  types << parse_type
132
146
  end
133
147
 
@@ -144,15 +158,13 @@ module ClickhouseRuby
144
158
 
145
159
  # First character must be letter or underscore
146
160
  unless @pos < @input.length && identifier_start_char?(@input[@pos])
147
- raise ParseError.new('Expected type name', position: @pos, input: @input)
161
+ raise ParseError.new("Expected type name", position: @pos, input: @input)
148
162
  end
149
163
 
150
164
  @pos += 1
151
165
 
152
166
  # Subsequent characters can be letters, digits, or underscores
153
- while @pos < @input.length && identifier_char?(@input[@pos])
154
- @pos += 1
155
- end
167
+ @pos += 1 while @pos < @input.length && identifier_char?(@input[@pos])
156
168
 
157
169
  @input[start_pos...@pos]
158
170
  end
@@ -187,9 +199,7 @@ module ClickhouseRuby
187
199
  def parse_numeric
188
200
  start_pos = @pos
189
201
 
190
- while @pos < @input.length && numeric_char?(@input[@pos])
191
- @pos += 1
192
- end
202
+ @pos += 1 while @pos < @input.length && numeric_char?(@input[@pos])
193
203
 
194
204
  @input[start_pos...@pos]
195
205
  end
@@ -203,7 +213,7 @@ module ClickhouseRuby
203
213
 
204
214
  while @pos < @input.length && @input[@pos] != "'"
205
215
  # Handle escaped quotes
206
- @pos += 1 if @input[@pos] == '\\' && @pos + 1 < @input.length
216
+ @pos += 1 if @input[@pos] == "\\" && @pos + 1 < @input.length
207
217
  @pos += 1
208
218
  end
209
219
 
@@ -226,7 +236,7 @@ module ClickhouseRuby
226
236
  # @raise [ParseError] if the character doesn't match
227
237
  def consume(expected)
228
238
  skip_whitespace
229
- actual = @pos < @input.length ? @input[@pos] : 'end of input'
239
+ actual = @pos < @input.length ? @input[@pos] : "end of input"
230
240
 
231
241
  unless actual == expected
232
242
  raise ParseError.new("Expected '#{expected}', got '#{actual}'", position: @pos, input: @input)