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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ module Types
5
+ # Type handler for ClickHouse Array type
6
+ #
7
+ # Arrays in ClickHouse are homogeneous - all elements must be the same type.
8
+ # Supports nested arrays like Array(Array(String)).
9
+ #
10
+ # @example
11
+ # type = Array.new('Array', element_type: String.new('String'))
12
+ # type.cast(['a', 'b', 'c']) # => ['a', 'b', 'c']
13
+ # type.serialize(['a', 'b']) # => "['a', 'b']"
14
+ #
15
+ class Array < Base
16
+ # @return [Base] the type of array elements
17
+ attr_reader :element_type
18
+
19
+ # @param name [String] the type name
20
+ # @param element_type [Base] the element type
21
+ def initialize(name, element_type: nil)
22
+ super(name)
23
+ @element_type = element_type || Base.new('String')
24
+ end
25
+
26
+ # Converts a Ruby value to an array
27
+ #
28
+ # @param value [Object] the value to convert
29
+ # @return [Array, nil] the array value
30
+ # @raise [TypeCastError] if the value cannot be converted
31
+ def cast(value)
32
+ return nil if value.nil?
33
+
34
+ arr = case value
35
+ when ::Array
36
+ value
37
+ when ::String
38
+ parse_array_string(value)
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
+ )
46
+ end
47
+
48
+ arr.map { |v| @element_type.cast(v) }
49
+ end
50
+
51
+ # Converts a value from ClickHouse to a Ruby Array
52
+ #
53
+ # @param value [Object] the value from ClickHouse
54
+ # @return [Array, nil] the array value
55
+ def deserialize(value)
56
+ return nil if value.nil?
57
+
58
+ arr = case value
59
+ when ::Array
60
+ value
61
+ when ::String
62
+ parse_array_string(value)
63
+ else
64
+ [value]
65
+ end
66
+
67
+ arr.map { |v| @element_type.deserialize(v) }
68
+ end
69
+
70
+ # Converts an array to SQL literal
71
+ #
72
+ # @param value [Array, nil] the value to serialize
73
+ # @return [String] the SQL literal
74
+ def serialize(value)
75
+ return 'NULL' if value.nil?
76
+
77
+ elements = value.map { |v| @element_type.serialize(v) }
78
+ "[#{elements.join(', ')}]"
79
+ end
80
+
81
+ # Returns the full type string including element type
82
+ #
83
+ # @return [String] the type string
84
+ def to_s
85
+ "Array(#{@element_type})"
86
+ end
87
+
88
+ private
89
+
90
+ # Parses a ClickHouse array string representation
91
+ #
92
+ # @param value [String] the string to parse
93
+ # @return [Array] the parsed array
94
+ def parse_array_string(value)
95
+ stripped = value.strip
96
+
97
+ # Handle empty array
98
+ return [] if stripped == '[]'
99
+
100
+ # 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
109
+
110
+ inner = stripped[1...-1]
111
+ return [] if inner.strip.empty?
112
+
113
+ # Parse elements (handles nested arrays and quoted strings)
114
+ parse_elements(inner)
115
+ end
116
+
117
+ # Parses comma-separated elements, handling nesting and quotes
118
+ #
119
+ # @param str [String] the inner array string
120
+ # @return [Array] the parsed elements
121
+ 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
158
+ end
159
+
160
+ # Don't forget the last element
161
+ elements << parse_element(current.strip) unless current.strip.empty?
162
+
163
+ elements
164
+ end
165
+
166
+ # Parses a single element, removing quotes if necessary
167
+ #
168
+ # @param str [String] the element string
169
+ # @return [Object] the parsed element
170
+ def parse_element(str)
171
+ # Remove surrounding quotes if present
172
+ 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
177
+ else
178
+ str
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ module Types
5
+ # Base class for all ClickHouse types
6
+ #
7
+ # Provides the interface for type conversion between Ruby and ClickHouse.
8
+ # Subclasses implement specific conversion logic.
9
+ #
10
+ # @abstract Subclasses should override {#cast}, {#deserialize}, and {#serialize}
11
+ #
12
+ class Base
13
+ # @return [String] the ClickHouse type name
14
+ attr_reader :name
15
+
16
+ # @param name [String] the ClickHouse type name
17
+ def initialize(name)
18
+ @name = name
19
+ end
20
+
21
+ # Converts a Ruby value to the appropriate type for this ClickHouse column
22
+ #
23
+ # @param value [Object] the value to convert
24
+ # @return [Object] the converted value
25
+ def cast(value)
26
+ value
27
+ end
28
+
29
+ # Converts a value from ClickHouse response format to Ruby
30
+ #
31
+ # @param value [Object] the value from ClickHouse
32
+ # @return [Object] the Ruby value
33
+ def deserialize(value)
34
+ value
35
+ end
36
+
37
+ # Converts a Ruby value to ClickHouse SQL literal format
38
+ #
39
+ # @param value [Object] the Ruby value
40
+ # @return [String] the SQL literal
41
+ def serialize(value)
42
+ value.to_s
43
+ end
44
+
45
+ # Returns whether NULL values are allowed
46
+ #
47
+ # @return [Boolean] true if nullable
48
+ def nullable?
49
+ false
50
+ end
51
+
52
+ # Returns the ClickHouse type string
53
+ #
54
+ # @return [String] the type string
55
+ def to_s
56
+ name
57
+ end
58
+
59
+ # Equality comparison
60
+ #
61
+ # @param other [Base] another type
62
+ # @return [Boolean] true if equal
63
+ def ==(other)
64
+ other.is_a?(Base) && other.name == name
65
+ end
66
+
67
+ alias eql? ==
68
+
69
+ # Hash code for use in hash keys
70
+ #
71
+ # @return [Integer] the hash code
72
+ def hash
73
+ name.hash
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ module Types
5
+ # Type handler for ClickHouse Bool type
6
+ #
7
+ # ClickHouse Bool is internally stored as UInt8 (0 or 1)
8
+ # but can accept various truthy/falsy values.
9
+ #
10
+ class Boolean < Base
11
+ # Values that represent true
12
+ TRUE_VALUES = [true, 1, '1', 'true', 'TRUE', 'True', 't', 'T', 'yes', 'YES', 'Yes', 'y', 'Y', 'on', 'ON', 'On'].freeze
13
+
14
+ # Values that represent false
15
+ FALSE_VALUES = [false, 0, '0', 'false', 'FALSE', 'False', 'f', 'F', 'no', 'NO', 'No', 'n', 'N', 'off', 'OFF', 'Off'].freeze
16
+
17
+ # Converts a Ruby value to a boolean
18
+ #
19
+ # @param value [Object] the value to convert
20
+ # @return [Boolean, nil] the boolean value
21
+ # @raise [TypeCastError] if the value cannot be interpreted as boolean
22
+ def cast(value)
23
+ return nil if value.nil?
24
+
25
+ if TRUE_VALUES.include?(value)
26
+ true
27
+ elsif FALSE_VALUES.include?(value)
28
+ false
29
+ else
30
+ raise TypeCastError.new(
31
+ "Cannot cast '#{value}' to Bool",
32
+ from_type: value.class.name,
33
+ to_type: name,
34
+ value: value
35
+ )
36
+ end
37
+ end
38
+
39
+ # Converts a value from ClickHouse to Ruby boolean
40
+ #
41
+ # @param value [Object] the value from ClickHouse
42
+ # @return [Boolean, nil] the boolean value
43
+ def deserialize(value)
44
+ return nil if value.nil?
45
+
46
+ case value
47
+ when true, 1, '1', 'true'
48
+ true
49
+ when false, 0, '0', 'false'
50
+ false
51
+ else
52
+ # Default to truthy evaluation
53
+ !!value
54
+ end
55
+ end
56
+
57
+ # Converts a boolean to SQL literal
58
+ #
59
+ # @param value [Boolean, nil] the value to serialize
60
+ # @return [String] the SQL literal (1 or 0)
61
+ def serialize(value)
62
+ return 'NULL' if value.nil?
63
+
64
+ value ? '1' : '0'
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'date'
5
+
6
+ module ClickhouseRuby
7
+ module Types
8
+ # Type handler for ClickHouse date and datetime types
9
+ #
10
+ # Handles: Date, Date32, DateTime, DateTime64
11
+ #
12
+ # Date types:
13
+ # - Date: days since 1970-01-01 (range: 1970-01-01 to 2149-06-06)
14
+ # - Date32: days since 1970-01-01 (range: 1900-01-01 to 2299-12-31)
15
+ #
16
+ # DateTime types:
17
+ # - DateTime: seconds since 1970-01-01 00:00:00 UTC
18
+ # - DateTime64(precision): with sub-second precision (0-9 decimal places)
19
+ #
20
+ class DateTime < Base
21
+ # The precision for DateTime64 (nil for other types)
22
+ # @return [Integer, nil] precision in decimal places
23
+ attr_reader :precision
24
+
25
+ # The timezone for DateTime types
26
+ # @return [String, nil] timezone name
27
+ attr_reader :timezone
28
+
29
+ def initialize(name, precision: nil, timezone: nil)
30
+ super(name)
31
+ @precision = precision
32
+ @timezone = timezone
33
+ end
34
+
35
+ # Converts a Ruby value to a Time or Date
36
+ #
37
+ # @param value [Object] the value to convert
38
+ # @return [Time, Date, nil] the time/date value
39
+ # @raise [TypeCastError] if the value cannot be converted
40
+ def cast(value)
41
+ return nil if value.nil?
42
+
43
+ case value
44
+ when ::Time
45
+ date_only? ? value.to_date : value
46
+ when ::Date
47
+ date_only? ? value : value.to_time
48
+ when ::String
49
+ parse_string(value)
50
+ when ::Integer
51
+ # Unix timestamp
52
+ date_only? ? Time.at(value).to_date : Time.at(value)
53
+ else
54
+ raise TypeCastError.new(
55
+ "Cannot cast #{value.class} to #{name}",
56
+ from_type: value.class.name,
57
+ to_type: name,
58
+ value: value
59
+ )
60
+ end
61
+ end
62
+
63
+ # Converts a value from ClickHouse to Ruby Time or Date
64
+ #
65
+ # @param value [Object] the value from ClickHouse
66
+ # @return [Time, Date, nil] the time/date value
67
+ def deserialize(value)
68
+ return nil if value.nil?
69
+
70
+ case value
71
+ when ::Time, ::Date
72
+ date_only? ? value.to_date : value.to_time
73
+ when ::String
74
+ parse_string(value)
75
+ when ::Integer
76
+ date_only? ? Time.at(value).to_date : Time.at(value)
77
+ else
78
+ parse_string(value.to_s)
79
+ end
80
+ end
81
+
82
+ # Converts a time/date to SQL literal
83
+ #
84
+ # @param value [Time, Date, nil] the value to serialize
85
+ # @return [String] the SQL literal
86
+ def serialize(value)
87
+ return 'NULL' if value.nil?
88
+
89
+ if date_only?
90
+ format_date(value)
91
+ else
92
+ format_datetime(value)
93
+ end
94
+ end
95
+
96
+ # Returns whether this is a date-only type (Date, Date32)
97
+ #
98
+ # @return [Boolean] true if date-only
99
+ def date_only?
100
+ name.start_with?('Date') && !name.start_with?('DateTime')
101
+ end
102
+
103
+ private
104
+
105
+ # Parses a string to a Time or Date
106
+ #
107
+ # @param value [String] the string to parse
108
+ # @return [Time, Date] the parsed value
109
+ # @raise [TypeCastError] if the string cannot be parsed
110
+ def parse_string(value)
111
+ stripped = value.strip
112
+
113
+ if stripped.empty?
114
+ raise TypeCastError.new(
115
+ "Cannot cast empty string to #{name}",
116
+ from_type: 'String',
117
+ to_type: name,
118
+ value: value
119
+ )
120
+ end
121
+
122
+ if date_only?
123
+ ::Date.parse(stripped)
124
+ else
125
+ ::Time.parse(stripped)
126
+ end
127
+ rescue ArgumentError => e
128
+ raise TypeCastError.new(
129
+ "Cannot cast '#{value}' to #{name}: #{e.message}",
130
+ from_type: 'String',
131
+ to_type: name,
132
+ value: value
133
+ )
134
+ end
135
+
136
+ # Formats a date value for SQL
137
+ #
138
+ # @param value [Date, Time] the value to format
139
+ # @return [String] the formatted SQL literal
140
+ def format_date(value)
141
+ date = value.respond_to?(:to_date) ? value.to_date : value
142
+ "'#{date.strftime('%Y-%m-%d')}'"
143
+ end
144
+
145
+ # Formats a datetime value for SQL
146
+ #
147
+ # @param value [Time, Date] the value to format
148
+ # @return [String] the formatted SQL literal
149
+ def format_datetime(value)
150
+ time = value.respond_to?(:to_time) ? value.to_time : value
151
+
152
+ if @precision && @precision > 0
153
+ # DateTime64 with fractional seconds
154
+ format_str = "%Y-%m-%d %H:%M:%S.%#{@precision}N"
155
+ "'#{time.strftime(format_str)}'"
156
+ else
157
+ # Regular DateTime
158
+ "'#{time.strftime('%Y-%m-%d %H:%M:%S')}'"
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ module Types
5
+ # Type handler for ClickHouse floating point types
6
+ #
7
+ # Handles: Float32, Float64
8
+ #
9
+ # Note: ClickHouse also supports special values: inf, -inf, nan
10
+ #
11
+ class Float < Base
12
+ # Converts a Ruby value to a float
13
+ #
14
+ # @param value [Object] the value to convert
15
+ # @return [Float, nil] the float value
16
+ # @raise [TypeCastError] if the value cannot be converted
17
+ def cast(value)
18
+ return nil if value.nil?
19
+
20
+ case value
21
+ when ::Float
22
+ value
23
+ when ::Integer
24
+ value.to_f
25
+ when ::String
26
+ parse_string(value)
27
+ when ::BigDecimal
28
+ value.to_f
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
+ )
36
+ end
37
+ end
38
+
39
+ # Converts a value from ClickHouse to Ruby Float
40
+ #
41
+ # @param value [Object] the value from ClickHouse
42
+ # @return [Float, nil] the float value
43
+ def deserialize(value)
44
+ return nil if value.nil?
45
+
46
+ case value
47
+ when ::Float
48
+ value
49
+ when ::String
50
+ parse_string(value)
51
+ else
52
+ value.to_f
53
+ end
54
+ end
55
+
56
+ # Converts a float to SQL literal
57
+ #
58
+ # @param value [Float, nil] the value to serialize
59
+ # @return [String] the SQL literal
60
+ def serialize(value)
61
+ return 'NULL' if value.nil?
62
+
63
+ if value.nan?
64
+ 'nan'
65
+ elsif value.infinite? == 1
66
+ 'inf'
67
+ elsif value.infinite? == -1
68
+ '-inf'
69
+ else
70
+ value.to_s
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ # Parses a string to a float
77
+ #
78
+ # @param value [String] the string to parse
79
+ # @return [Float] the parsed float
80
+ # @raise [TypeCastError] if the string is not a valid float
81
+ def parse_string(value)
82
+ stripped = value.strip.downcase
83
+
84
+ # Handle special values
85
+ case stripped
86
+ when 'inf', '+inf', 'infinity', '+infinity'
87
+ ::Float::INFINITY
88
+ when '-inf', '-infinity'
89
+ -::Float::INFINITY
90
+ when 'nan'
91
+ ::Float::NAN
92
+ else
93
+ # 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
102
+
103
+ Float(stripped)
104
+ end
105
+ rescue ArgumentError
106
+ raise TypeCastError.new(
107
+ "Cannot cast '#{value}' to #{name}",
108
+ from_type: 'String',
109
+ to_type: name,
110
+ value: value
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end