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
@@ -36,7 +36,7 @@ module ClickhouseRuby
36
36
  # @param type_class [Class] the type class
37
37
  def register(name, type_class)
38
38
  @types[name] = type_class
39
- @cache.clear # Invalidate cache when types change
39
+ @cache.clear # Invalidate cache when types change
40
40
  end
41
41
 
42
42
  # Looks up a type by its ClickHouse type string
@@ -62,43 +62,55 @@ module ClickhouseRuby
62
62
  # Registers all default ClickHouse types
63
63
  def register_defaults
64
64
  # Integer types
65
- register('Int8', Integer)
66
- register('Int16', Integer)
67
- register('Int32', Integer)
68
- register('Int64', Integer)
69
- register('Int128', Integer)
70
- register('Int256', Integer)
71
- register('UInt8', Integer)
72
- register('UInt16', Integer)
73
- register('UInt32', Integer)
74
- register('UInt64', Integer)
75
- register('UInt128', Integer)
76
- register('UInt256', Integer)
65
+ register("Int8", Integer)
66
+ register("Int16", Integer)
67
+ register("Int32", Integer)
68
+ register("Int64", Integer)
69
+ register("Int128", Integer)
70
+ register("Int256", Integer)
71
+ register("UInt8", Integer)
72
+ register("UInt16", Integer)
73
+ register("UInt32", Integer)
74
+ register("UInt64", Integer)
75
+ register("UInt128", Integer)
76
+ register("UInt256", Integer)
77
77
 
78
78
  # Float types
79
- register('Float32', Float)
80
- register('Float64', Float)
79
+ register("Float32", Float)
80
+ register("Float64", Float)
81
+
82
+ # Decimal types
83
+ register("Decimal", Decimal)
84
+ register("Decimal32", Decimal)
85
+ register("Decimal64", Decimal)
86
+ register("Decimal128", Decimal)
87
+ register("Decimal256", Decimal)
81
88
 
82
89
  # String types
83
- register('String', String)
84
- register('FixedString', String)
90
+ register("String", String)
91
+ register("FixedString", String)
85
92
 
86
93
  # Date/Time types
87
- register('Date', DateTime)
88
- register('Date32', DateTime)
89
- register('DateTime', DateTime)
90
- register('DateTime64', DateTime)
94
+ register("Date", DateTime)
95
+ register("Date32", DateTime)
96
+ register("DateTime", DateTime)
97
+ register("DateTime64", DateTime)
91
98
 
92
99
  # Other basic types
93
- register('UUID', UUID)
94
- register('Bool', Boolean)
100
+ register("UUID", UUID)
101
+ register("Bool", Boolean)
95
102
 
96
103
  # Complex/wrapper types
97
- register('Array', Array)
98
- register('Map', Map)
99
- register('Tuple', Tuple)
100
- register('Nullable', Nullable)
101
- register('LowCardinality', LowCardinality)
104
+ register("Array", Array)
105
+ register("Map", Map)
106
+ register("Tuple", Tuple)
107
+ register("Nullable", Nullable)
108
+ register("LowCardinality", LowCardinality)
109
+
110
+ # Enum types
111
+ register("Enum", Enum)
112
+ register("Enum8", Enum)
113
+ register("Enum16", Enum)
102
114
  end
103
115
 
104
116
  private
@@ -142,7 +154,6 @@ module ClickhouseRuby
142
154
  type_class.new(type_name)
143
155
  end
144
156
  end
145
-
146
157
  end
147
158
  end
148
159
  end
@@ -29,9 +29,7 @@ module ClickhouseRuby
29
29
  str = value.to_s
30
30
 
31
31
  # For FixedString, pad or truncate to length
32
- if @length
33
- str = str.ljust(@length, "\0")[0, @length]
34
- end
32
+ str = str.ljust(@length, "\0")[0, @length] if @length
35
33
 
36
34
  str
37
35
  end
@@ -46,9 +44,7 @@ module ClickhouseRuby
46
44
  str = value.to_s
47
45
 
48
46
  # For FixedString, remove trailing null bytes
49
- if @length
50
- str = str.gsub(/\0+\z/, '')
51
- end
47
+ str = str.gsub(/\0+\z/, "") if @length
52
48
 
53
49
  str
54
50
  end
@@ -58,7 +54,7 @@ module ClickhouseRuby
58
54
  # @param value [String, nil] the value to serialize
59
55
  # @return [String] the SQL literal
60
56
  def serialize(value)
61
- return 'NULL' if value.nil?
57
+ return "NULL" if value.nil?
62
58
 
63
59
  escaped = escape_string(value.to_s)
64
60
  "'#{escaped}'"
@@ -71,12 +67,12 @@ module ClickhouseRuby
71
67
  # @param value [String] the string to escape
72
68
  # @return [String] the escaped string
73
69
  def escape_string(value)
74
- value.gsub("\\", "\\\\")
75
- .gsub("'", "\\'")
76
- .gsub("\n", "\\n")
77
- .gsub("\r", "\\r")
78
- .gsub("\t", "\\t")
79
- .gsub("\0", "\\0")
70
+ value.gsub("\\", "\\\\\\\\")
71
+ .gsub("'", "\\\\'")
72
+ .gsub("\n", "\\\\n")
73
+ .gsub("\r", "\\\\r")
74
+ .gsub("\t", "\\\\t")
75
+ .gsub("\0", "\\\\0")
80
76
  end
81
77
  end
82
78
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ module Types
5
+ # Shared utilities for parsing ClickHouse string representations
6
+ # of complex types (arrays, tuples, maps)
7
+ #
8
+ # These methods handle the common patterns of parsing nested structures
9
+ # with proper handling of quotes, escape characters, and bracket depth.
10
+ #
11
+ module StringParser
12
+ module_function
13
+
14
+ # Parses a comma-separated list of elements, respecting nested structures and quotes
15
+ #
16
+ # @param str [String] the string to parse
17
+ # @param open_brackets [Array<String>] characters that increase nesting depth
18
+ # @param close_brackets [Array<String>] characters that decrease nesting depth
19
+ # @return [Array<String>] parsed elements
20
+ #
21
+ # @example
22
+ # StringParser.parse_delimited("a, b, c")
23
+ # # => ["a", "b", "c"]
24
+ #
25
+ # StringParser.parse_delimited("'hello', [1, 2], 'world'")
26
+ # # => ["'hello'", "[1, 2]", "'world'"]
27
+ #
28
+ def parse_delimited(str, open_brackets: ["[", "(", "{"], close_brackets: ["]", ")", "}"])
29
+ elements = []
30
+ current = +""
31
+ depth = 0
32
+ in_string = false
33
+ escape_next = false
34
+
35
+ str.each_char do |char|
36
+ if escape_next
37
+ current << char
38
+ escape_next = false
39
+ next
40
+ end
41
+
42
+ case char
43
+ when "\\"
44
+ escape_next = true
45
+ current << char
46
+ when "'"
47
+ in_string = !in_string
48
+ current << char
49
+ when *open_brackets
50
+ depth += 1 unless in_string
51
+ current << char
52
+ when *close_brackets
53
+ depth -= 1 unless in_string
54
+ current << char
55
+ when ","
56
+ if depth.zero? && !in_string
57
+ elements << current.strip
58
+ current = +""
59
+ else
60
+ current << char
61
+ end
62
+ else
63
+ current << char
64
+ end
65
+ end
66
+
67
+ elements << current.strip unless current.strip.empty?
68
+ elements
69
+ end
70
+
71
+ # Removes surrounding single quotes and unescapes content
72
+ #
73
+ # @param str [String] potentially quoted string
74
+ # @return [String] unquoted string with escapes processed
75
+ #
76
+ # @example
77
+ # StringParser.unquote("'hello'")
78
+ # # => "hello"
79
+ #
80
+ # StringParser.unquote("'it\\'s'")
81
+ # # => "it's"
82
+ #
83
+ # StringParser.unquote("123")
84
+ # # => "123"
85
+ #
86
+ def unquote(str)
87
+ str = str.strip
88
+ if str.start_with?("'") && str.end_with?("'") && str.length >= 2
89
+ str[1...-1].gsub("\\'", "'")
90
+ else
91
+ str
92
+ end
93
+ end
94
+
95
+ # Validates and extracts content from a bracketed string
96
+ #
97
+ # @param str [String] bracketed string like "[...]" or "(...)"
98
+ # @param open_bracket [String] expected opening bracket
99
+ # @param close_bracket [String] expected closing bracket
100
+ # @return [String] inner content (may be empty)
101
+ # @raise [ArgumentError] if format is invalid
102
+ #
103
+ # @example
104
+ # StringParser.extract_bracketed("[1, 2, 3]", "[", "]")
105
+ # # => "1, 2, 3"
106
+ #
107
+ def extract_bracketed(str, open_bracket, close_bracket)
108
+ str = str.strip
109
+ unless str.start_with?(open_bracket) && str.end_with?(close_bracket) && str.length >= 2
110
+ raise ArgumentError, "Expected #{open_bracket}...#{close_bracket} format, got: '#{str}'"
111
+ end
112
+
113
+ str[1...-1]
114
+ end
115
+
116
+ # Parses elements and unquotes them in one step
117
+ #
118
+ # @param str [String] the string to parse
119
+ # @param open_brackets [Array<String>] characters that increase nesting depth
120
+ # @param close_brackets [Array<String>] characters that decrease nesting depth
121
+ # @return [Array<String>] parsed and unquoted elements
122
+ #
123
+ def parse_and_unquote(str, open_brackets: ["[", "(", "{"], close_brackets: ["]", ")", "}"])
124
+ parse_delimited(str, open_brackets: open_brackets, close_brackets: close_brackets).map do |el|
125
+ # Only unquote simple quoted strings, preserve nested structures
126
+ if el.start_with?("'") && el.end_with?("'") && !el.include?("[") && !el.include?("(") && !el.include?("{")
127
+ unquote(el)
128
+ else
129
+ el
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -37,12 +37,7 @@ module ClickhouseRuby
37
37
  when ::String
38
38
  parse_tuple_string(value)
39
39
  else
40
- raise TypeCastError.new(
41
- "Cannot cast #{value.class} to Tuple",
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 Tuple")
46
41
  end
47
42
 
48
43
  cast_elements(arr)
@@ -72,21 +67,21 @@ 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.each_with_index.map do |v, i|
78
- type = @element_types[i] || Base.new('String')
73
+ type = @element_types[i] || Base.new("String")
79
74
  type.serialize(v)
80
75
  end
81
76
 
82
- "(#{elements.join(', ')})"
77
+ "(#{elements.join(", ")})"
83
78
  end
84
79
 
85
80
  # Returns the full type string including element types
86
81
  #
87
82
  # @return [String] the type string
88
83
  def to_s
89
- type_strs = @element_types.map(&:to_s).join(', ')
84
+ type_strs = @element_types.map(&:to_s).join(", ")
90
85
  "Tuple(#{type_strs})"
91
86
  end
92
87
 
@@ -98,7 +93,7 @@ module ClickhouseRuby
98
93
  # @return [Array] the cast array
99
94
  def cast_elements(arr)
100
95
  arr.each_with_index.map do |v, i|
101
- type = @element_types[i] || Base.new('String')
96
+ type = @element_types[i] || Base.new("String")
102
97
  type.cast(v)
103
98
  end
104
99
  end
@@ -109,7 +104,7 @@ module ClickhouseRuby
109
104
  # @return [Array] the deserialized array
110
105
  def deserialize_elements(arr)
111
106
  arr.each_with_index.map do |v, i|
112
- type = @element_types[i] || Base.new('String')
107
+ type = @element_types[i] || Base.new("String")
113
108
  type.deserialize(v)
114
109
  end
115
110
  end
@@ -122,17 +117,10 @@ module ClickhouseRuby
122
117
  stripped = value.strip
123
118
 
124
119
  # Handle empty tuple
125
- return [] if stripped == '()'
120
+ return [] if stripped == "()"
126
121
 
127
122
  # Remove outer parentheses
128
- unless stripped.start_with?('(') && stripped.end_with?(')')
129
- raise TypeCastError.new(
130
- "Invalid tuple format: '#{value}'",
131
- from_type: 'String',
132
- to_type: to_s,
133
- value: value
134
- )
135
- end
123
+ raise_format_error(value, "tuple") unless stripped.start_with?("(") && stripped.end_with?(")")
136
124
 
137
125
  inner = stripped[1...-1]
138
126
  return [] if inner.strip.empty?
@@ -146,48 +134,7 @@ module ClickhouseRuby
146
134
  # @param str [String] the inner tuple string
147
135
  # @return [Array] the parsed elements
148
136
  def parse_elements(str)
149
- elements = []
150
- current = ''
151
- depth = 0
152
- in_string = false
153
- escape_next = false
154
-
155
- str.each_char do |char|
156
- if escape_next
157
- current += char
158
- escape_next = false
159
- next
160
- end
161
-
162
- case char
163
- when '\\'
164
- escape_next = true
165
- current += char
166
- when "'"
167
- in_string = !in_string
168
- current += char
169
- when '(', '[', '{'
170
- depth += 1 unless in_string
171
- current += char
172
- when ')', ']', '}'
173
- depth -= 1 unless in_string
174
- current += char
175
- when ','
176
- if depth.zero? && !in_string
177
- elements << parse_element(current.strip)
178
- current = ''
179
- else
180
- current += char
181
- end
182
- else
183
- current += char
184
- end
185
- end
186
-
187
- # Don't forget the last element
188
- elements << parse_element(current.strip) unless current.strip.empty?
189
-
190
- elements
137
+ StringParser.parse_delimited(str).map { |el| parse_element(el) }
191
138
  end
192
139
 
193
140
  # Parses a single element, removing quotes if necessary
@@ -195,11 +142,7 @@ module ClickhouseRuby
195
142
  # @param str [String] the element string
196
143
  # @return [Object] the parsed element
197
144
  def parse_element(str)
198
- if str.start_with?("'") && str.end_with?("'")
199
- str[1...-1].gsub("\\'", "'")
200
- else
201
- str
202
- end
145
+ StringParser.unquote(str)
203
146
  end
204
147
  end
205
148
  end
@@ -8,17 +8,19 @@ module ClickhouseRuby
8
8
  # in the format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
9
9
  #
10
10
  class UUID < Base
11
+ include NullSafe
12
+
11
13
  # UUID regex pattern
12
14
  UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
13
15
 
16
+ protected
17
+
14
18
  # Converts a Ruby value to a UUID string
15
19
  #
16
- # @param value [Object] the value to convert
17
- # @return [String, nil] the UUID string
20
+ # @param value [Object] the value to convert (guaranteed non-nil)
21
+ # @return [String] the UUID string
18
22
  # @raise [TypeCastError] if the value is not a valid UUID
19
- def cast(value)
20
- return nil if value.nil?
21
-
23
+ def cast_value(value)
22
24
  str = normalize_uuid(value)
23
25
  validate_uuid!(str, value)
24
26
  str
@@ -26,21 +28,17 @@ module ClickhouseRuby
26
28
 
27
29
  # Converts a value from ClickHouse to a UUID string
28
30
  #
29
- # @param value [Object] the value from ClickHouse
30
- # @return [String, nil] the UUID string
31
- def deserialize(value)
32
- return nil if value.nil?
33
-
31
+ # @param value [Object] the value from ClickHouse (guaranteed non-nil)
32
+ # @return [String] the UUID string
33
+ def deserialize_value(value)
34
34
  normalize_uuid(value)
35
35
  end
36
36
 
37
37
  # Converts a UUID to SQL literal
38
38
  #
39
- # @param value [String, nil] the UUID value
39
+ # @param value [String] the UUID value (guaranteed non-nil)
40
40
  # @return [String] the SQL literal
41
- def serialize(value)
42
- return 'NULL' if value.nil?
43
-
41
+ def serialize_value(value)
44
42
  "'#{normalize_uuid(value)}'"
45
43
  end
46
44
 
@@ -54,10 +52,10 @@ module ClickhouseRuby
54
52
  str = value.to_s.strip.downcase
55
53
 
56
54
  # Remove braces if present
57
- str = str.gsub(/[{}]/, '')
55
+ str = str.gsub(/[{}]/, "")
58
56
 
59
57
  # If no hyphens, add them
60
- if str.length == 32 && !str.include?('-')
58
+ if str.length == 32 && !str.include?("-")
61
59
  str = "#{str[0..7]}-#{str[8..11]}-#{str[12..15]}-#{str[16..19]}-#{str[20..31]}"
62
60
  end
63
61
 
@@ -72,12 +70,7 @@ module ClickhouseRuby
72
70
  def validate_uuid!(str, original)
73
71
  return if str.match?(UUID_PATTERN)
74
72
 
75
- raise TypeCastError.new(
76
- "Invalid UUID format: '#{original}'",
77
- from_type: original.class.name,
78
- to_type: name,
79
- value: original
80
- )
73
+ raise_format_error(original, "UUID")
81
74
  end
82
75
  end
83
76
  end
@@ -1,20 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Load base class first, then parser, then specific types, then registry last
4
- require_relative 'types/base'
5
- require_relative 'types/parser'
6
- require_relative 'types/integer'
7
- require_relative 'types/float'
8
- require_relative 'types/string'
9
- require_relative 'types/date_time'
10
- require_relative 'types/uuid'
11
- require_relative 'types/boolean'
12
- require_relative 'types/array'
13
- require_relative 'types/map'
14
- require_relative 'types/tuple'
15
- require_relative 'types/nullable'
16
- require_relative 'types/low_cardinality'
17
- require_relative 'types/registry'
3
+ # Load base class first, then modules, then parser, then specific types, then registry last
4
+ require_relative "types/base"
5
+ require_relative "types/null_safe"
6
+ require_relative "types/string_parser"
7
+ require_relative "types/parser"
8
+ require_relative "types/integer"
9
+ require_relative "types/float"
10
+ require_relative "types/decimal"
11
+ require_relative "types/string"
12
+ require_relative "types/date_time"
13
+ require_relative "types/uuid"
14
+ require_relative "types/boolean"
15
+ require_relative "types/array"
16
+ require_relative "types/map"
17
+ require_relative "types/tuple"
18
+ require_relative "types/nullable"
19
+ require_relative "types/low_cardinality"
20
+ require_relative "types/enum"
21
+ require_relative "types/registry"
18
22
 
19
23
  module ClickhouseRuby
20
24
  # Type system for mapping between ClickHouse and Ruby types
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClickhouseRuby
4
- VERSION = '0.1.0'
4
+ VERSION = "0.2.0"
5
5
  end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'clickhouse_ruby/version'
4
- require_relative 'clickhouse_ruby/errors'
5
- require_relative 'clickhouse_ruby/configuration'
6
- require_relative 'clickhouse_ruby/types'
7
- require_relative 'clickhouse_ruby/result'
8
- require_relative 'clickhouse_ruby/client'
9
- require_relative 'clickhouse_ruby/connection'
10
- require_relative 'clickhouse_ruby/connection_pool'
3
+ require_relative "clickhouse_ruby/version"
4
+ require_relative "clickhouse_ruby/errors"
5
+ require_relative "clickhouse_ruby/configuration"
6
+ require_relative "clickhouse_ruby/types"
7
+ require_relative "clickhouse_ruby/result"
8
+ require_relative "clickhouse_ruby/retry_handler"
9
+ require_relative "clickhouse_ruby/streaming_result"
10
+ require_relative "clickhouse_ruby/client"
11
+ require_relative "clickhouse_ruby/connection"
12
+ require_relative "clickhouse_ruby/connection_pool"
11
13
 
12
14
  # ClickhouseRuby - Ruby/ActiveRecord integration for ClickHouse
13
15
  #
@@ -96,6 +98,4 @@ module ClickhouseRuby
96
98
  end
97
99
 
98
100
  # Load ActiveRecord integration if ActiveRecord is available
99
- if defined?(ActiveRecord)
100
- require_relative 'clickhouse_ruby/active_record'
101
- end
101
+ require_relative "clickhouse_ruby/active_record" if defined?(ActiveRecord)