activerecord-clickhouse-adapter 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +313 -0
- data/lib/active_record/connection_adapters/clickhouse/database_statements.rb +464 -0
- data/lib/active_record/connection_adapters/clickhouse/error_translation.rb +52 -0
- data/lib/active_record/connection_adapters/clickhouse/gem_version.rb +9 -0
- data/lib/active_record/connection_adapters/clickhouse/http_connection.rb +233 -0
- data/lib/active_record/connection_adapters/clickhouse/querying.rb +417 -0
- data/lib/active_record/connection_adapters/clickhouse/quoting.rb +92 -0
- data/lib/active_record/connection_adapters/clickhouse/row_binary.rb +190 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +146 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_dumper.rb +201 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +722 -0
- data/lib/active_record/connection_adapters/clickhouse/type_parser.rb +207 -0
- data/lib/active_record/connection_adapters/clickhouse/types.rb +238 -0
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +126 -0
- data/lib/active_record/tasks/clickhouse_database_tasks.rb +91 -0
- data/lib/activerecord-clickhouse-adapter.rb +21 -0
- data/lib/arel/visitors/clickhouse.rb +172 -0
- metadata +82 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module ClickHouse
|
|
6
|
+
# Recursive-descent parser for ClickHouse type strings from
|
|
7
|
+
# JSONCompactEachRowWithNamesAndTypes. Structure only — no regex nesting.
|
|
8
|
+
class TypeParser
|
|
9
|
+
Error = Class.new(ArgumentError) # rubocop:disable Style/EmptyClassDefinition
|
|
10
|
+
Node = Data.define(:name, :args)
|
|
11
|
+
|
|
12
|
+
def self.parse(type_string) = new(type_string).parse
|
|
13
|
+
|
|
14
|
+
def initialize(source)
|
|
15
|
+
@source = source.to_s
|
|
16
|
+
@position = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse
|
|
20
|
+
raise Error, "empty type string" if @source.strip.empty?
|
|
21
|
+
|
|
22
|
+
node = parse_type
|
|
23
|
+
skip_whitespace
|
|
24
|
+
raise Error, "trailing input after type" unless eof?
|
|
25
|
+
|
|
26
|
+
node
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def parse_type = parse_type_with_name(parse_identifier)
|
|
32
|
+
|
|
33
|
+
def parse_type_with_name(name) # rubocop:disable Metrics/MethodLength
|
|
34
|
+
return Node.new(name, []) unless consume("(")
|
|
35
|
+
|
|
36
|
+
args = case name
|
|
37
|
+
when "Enum8", "Enum16" then parse_enum_args
|
|
38
|
+
when "Tuple" then parse_tuple_args
|
|
39
|
+
when "SimpleAggregateFunction", "AggregateFunction" then parse_aggregate_args
|
|
40
|
+
when "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256",
|
|
41
|
+
"FixedString", "DateTime", "DateTime64" then parse_literal_args
|
|
42
|
+
else parse_type_args
|
|
43
|
+
end
|
|
44
|
+
expect(")")
|
|
45
|
+
Node.new(name, args)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_type_args = parse_comma_separated { parse_type }
|
|
49
|
+
|
|
50
|
+
def parse_literal_args = parse_comma_separated { parse_literal }
|
|
51
|
+
|
|
52
|
+
def parse_enum_args = parse_comma_separated { parse_enum_mapping }
|
|
53
|
+
|
|
54
|
+
def parse_aggregate_args
|
|
55
|
+
function_name = parse_aggregate_function_label
|
|
56
|
+
expect(",")
|
|
57
|
+
[function_name, *parse_type_args]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Parametric combinators (quantile(0.95), topK(10)) carry parameters in the
|
|
61
|
+
# function name itself; the raw balanced-paren text stays in the label. String
|
|
62
|
+
# parameters containing parens would break this — none exist in practice.
|
|
63
|
+
def parse_aggregate_function_label
|
|
64
|
+
name = parse_identifier
|
|
65
|
+
skip_whitespace
|
|
66
|
+
return name unless peek("(")
|
|
67
|
+
|
|
68
|
+
"#{name}#{consume_balanced_parens}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def consume_balanced_parens
|
|
72
|
+
start = @position
|
|
73
|
+
depth = 0
|
|
74
|
+
loop do
|
|
75
|
+
char = @source[@position] or raise Error, "unterminated aggregate function parameters"
|
|
76
|
+
depth += 1 if char == "("
|
|
77
|
+
depth -= 1 if char == ")"
|
|
78
|
+
@position += 1
|
|
79
|
+
break if depth.zero?
|
|
80
|
+
end
|
|
81
|
+
@source[start...@position]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_tuple_args
|
|
85
|
+
return [] if peek(")")
|
|
86
|
+
|
|
87
|
+
parse_comma_separated { parse_tuple_element }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_tuple_element
|
|
91
|
+
first = parse_identifier
|
|
92
|
+
skip_whitespace
|
|
93
|
+
|
|
94
|
+
if identifier_start?
|
|
95
|
+
[first, parse_type]
|
|
96
|
+
else
|
|
97
|
+
parse_type_with_name(first)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_enum_mapping
|
|
102
|
+
label = parse_quoted_string
|
|
103
|
+
expect("=")
|
|
104
|
+
[label, parse_signed_integer]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_literal
|
|
108
|
+
skip_whitespace
|
|
109
|
+
return parse_quoted_string if peek("'")
|
|
110
|
+
return parse_signed_integer if digit? || peek("-")
|
|
111
|
+
|
|
112
|
+
raise Error, "expected literal at position #{@position}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_comma_separated
|
|
116
|
+
skip_whitespace
|
|
117
|
+
return [] if peek(")")
|
|
118
|
+
|
|
119
|
+
values = [yield]
|
|
120
|
+
while consume(",")
|
|
121
|
+
skip_whitespace
|
|
122
|
+
raise Error, "trailing comma in parameters" if peek(")")
|
|
123
|
+
|
|
124
|
+
values << yield
|
|
125
|
+
end
|
|
126
|
+
values
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_identifier
|
|
130
|
+
skip_whitespace
|
|
131
|
+
raise Error, "expected identifier at position #{@position}" unless identifier_start?
|
|
132
|
+
|
|
133
|
+
start = @position
|
|
134
|
+
@position += 1 while @position < @source.length && identifier_char?(@source[@position])
|
|
135
|
+
@source[start...@position]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_quoted_string # rubocop:disable Metrics/MethodLength
|
|
139
|
+
skip_whitespace
|
|
140
|
+
expect("'")
|
|
141
|
+
chars = +""
|
|
142
|
+
while @position < @source.length
|
|
143
|
+
char = @source[@position]
|
|
144
|
+
if char == "\\"
|
|
145
|
+
@position += 1
|
|
146
|
+
raise Error, "unterminated escape in string" if eof?
|
|
147
|
+
|
|
148
|
+
chars << @source[@position]
|
|
149
|
+
@position += 1
|
|
150
|
+
elsif char == "'"
|
|
151
|
+
@position += 1
|
|
152
|
+
return chars
|
|
153
|
+
else
|
|
154
|
+
chars << char
|
|
155
|
+
@position += 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
raise Error, "unterminated string"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_signed_integer
|
|
162
|
+
skip_whitespace
|
|
163
|
+
start = @position
|
|
164
|
+
@position += 1 if peek("-")
|
|
165
|
+
raise Error, "expected integer at position #{@position}" unless digit?
|
|
166
|
+
|
|
167
|
+
@position += 1 while digit?
|
|
168
|
+
Integer(@source[start...@position])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def expect(token)
|
|
172
|
+
skip_whitespace
|
|
173
|
+
return if consume(token)
|
|
174
|
+
|
|
175
|
+
raise Error, "expected #{token.inspect} at position #{@position}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def consume(token)
|
|
179
|
+
skip_whitespace
|
|
180
|
+
return false unless @source[@position, token.length] == token
|
|
181
|
+
|
|
182
|
+
@position += token.length
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def peek(token)
|
|
187
|
+
skip_whitespace
|
|
188
|
+
@source[@position, token.length] == token
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def skip_whitespace
|
|
192
|
+
@position += 1 while @position < @source.length && @source[@position].match?(/\s/)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def eof? = @position >= @source.length
|
|
196
|
+
|
|
197
|
+
def peek_char = eof? ? nil : @source[@position]
|
|
198
|
+
|
|
199
|
+
def digit? = peek_char&.match?(/\d/)
|
|
200
|
+
|
|
201
|
+
def identifier_start? = peek_char&.match?(/[A-Za-z_]/)
|
|
202
|
+
|
|
203
|
+
def identifier_char?(char) = char.match?(/[A-Za-z0-9_]/)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "json"
|
|
7
|
+
require "singleton"
|
|
8
|
+
|
|
9
|
+
module ActiveRecord
|
|
10
|
+
module ConnectionAdapters
|
|
11
|
+
module ClickHouse
|
|
12
|
+
# Builds eager value casters from ClickHouse type AST nodes.
|
|
13
|
+
module Types
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def caster_for(type_string) = build(TypeParser.parse(type_string))
|
|
17
|
+
|
|
18
|
+
# ActiveModel type for schema introspection (Column#type); wrappers unwrap.
|
|
19
|
+
def active_record_cast_type(type_string) = ar_cast_type(TypeParser.parse(type_string))
|
|
20
|
+
|
|
21
|
+
def ar_cast_type(node) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
22
|
+
case node.name
|
|
23
|
+
when "Nullable", "LowCardinality" then ar_cast_type(node.args.fetch(0))
|
|
24
|
+
when "SimpleAggregateFunction" then ar_cast_type(node.args.fetch(1))
|
|
25
|
+
when /\AU?Int(8|16|32|64|128|256)\z/ then ActiveModel::Type::Integer.new(limit: Regexp.last_match(1).to_i / 8)
|
|
26
|
+
when "Float32", "Float64" then ActiveModel::Type::Float.new
|
|
27
|
+
when "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256"
|
|
28
|
+
precision, scale = node.args.grep(Integer)
|
|
29
|
+
ActiveModel::Type::Decimal.new(precision: precision, scale: scale)
|
|
30
|
+
when "Date", "Date32" then ActiveModel::Type::Date.new
|
|
31
|
+
when "DateTime" then ActiveModel::Type::DateTime.new
|
|
32
|
+
when "DateTime64" then ActiveModel::Type::DateTime.new(precision: node.args.first)
|
|
33
|
+
when "Bool" then ActiveModel::Type::Boolean.new
|
|
34
|
+
when "String", "FixedString", "Enum8", "Enum16", "UUID", "IPv4", "IPv6" then ActiveModel::Type::String.new
|
|
35
|
+
when "JSON" then ActiveRecord::Type::Json.new
|
|
36
|
+
else ActiveModel::Type::Value.new
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build(node) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
41
|
+
case node.name
|
|
42
|
+
when "Nullable" then Nullable.new(build(node.args.fetch(0)))
|
|
43
|
+
when "LowCardinality" then build(node.args.fetch(0))
|
|
44
|
+
when "Array" then ArrayCaster.new(build(node.args.fetch(0)))
|
|
45
|
+
when "Map" then MapCaster.new(build(node.args.fetch(0)), build(node.args.fetch(1)))
|
|
46
|
+
when "Tuple" then tuple_caster(node.args)
|
|
47
|
+
when "SimpleAggregateFunction" then build(node.args.fetch(1))
|
|
48
|
+
when "AggregateFunction" then Passthrough.instance
|
|
49
|
+
when "Bool" then BoolCaster.instance
|
|
50
|
+
when "String", "FixedString", "UUID", "Enum8", "Enum16" then StringCaster.instance
|
|
51
|
+
when "JSON" then JsonCaster.instance
|
|
52
|
+
when "Nothing" then NullCaster.instance
|
|
53
|
+
when "Date", "Date32" then DateCaster.instance
|
|
54
|
+
when "DateTime" then DateTimeCaster.new(node.args[0])
|
|
55
|
+
when "DateTime64" then DateTimeCaster.new(node.args[1])
|
|
56
|
+
when "IPv4", "IPv6" then IpCaster.instance
|
|
57
|
+
when "Float32", "Float64" then FloatCaster.instance
|
|
58
|
+
when "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256" then DecimalCaster.instance
|
|
59
|
+
when /\A(?:UInt|Int)(?:8|16|32|64|128|256)\z/ then IntegerCaster.instance
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "unsupported ClickHouse type family: #{node.name}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tuple_caster(args)
|
|
66
|
+
if args.first.is_a?(Array)
|
|
67
|
+
NamedTupleCaster.new(args.to_h { |name, type_node| [name, build(type_node)] })
|
|
68
|
+
else
|
|
69
|
+
PositionalTupleCaster.new(args.map { |type_node| build(type_node) })
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :tuple_caster
|
|
73
|
+
|
|
74
|
+
class Passthrough
|
|
75
|
+
include Singleton
|
|
76
|
+
|
|
77
|
+
def cast(value) = value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class NullCaster
|
|
81
|
+
include Singleton
|
|
82
|
+
|
|
83
|
+
def cast(_value) = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class Nullable
|
|
87
|
+
def initialize(inner) = @inner = inner
|
|
88
|
+
def cast(value) = value.nil? ? nil : @inner.cast(value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class IntegerCaster
|
|
92
|
+
include Singleton
|
|
93
|
+
|
|
94
|
+
def cast(value) = Integer(value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class FloatCaster
|
|
98
|
+
include Singleton
|
|
99
|
+
|
|
100
|
+
# quote_denormals=1 delivers these as strings; anything else must be numeric.
|
|
101
|
+
DENORMAL_VALUES = {
|
|
102
|
+
"nan" => Float::NAN, "inf" => Float::INFINITY,
|
|
103
|
+
"+inf" => Float::INFINITY, "-inf" => -Float::INFINITY
|
|
104
|
+
}.freeze
|
|
105
|
+
|
|
106
|
+
def cast(value) = DENORMAL_VALUES.fetch(value) { Float(value) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class DecimalCaster
|
|
110
|
+
include Singleton
|
|
111
|
+
|
|
112
|
+
def cast(value) = value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class StringCaster
|
|
116
|
+
include Singleton
|
|
117
|
+
|
|
118
|
+
def cast(value) = value.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class BoolCaster
|
|
122
|
+
include Singleton
|
|
123
|
+
|
|
124
|
+
def cast(value) = [true, 1, "1", "true"].include?(value)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class DateCaster
|
|
128
|
+
include Singleton
|
|
129
|
+
|
|
130
|
+
def cast(value) = value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns plain Time instances (like every built-in adapter's database layer);
|
|
134
|
+
# TimeWithZone wrapping belongs to the attribute layer.
|
|
135
|
+
class DateTimeCaster
|
|
136
|
+
# The server always sends this exact shape; building the Time directly is 3.3x
|
|
137
|
+
# faster than zone.parse and allocates a fraction of the memory
|
|
138
|
+
# (benchmarks/BASELINE.md).
|
|
139
|
+
WIRE_FORMAT = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d+))?\z/
|
|
140
|
+
|
|
141
|
+
def initialize(time_zone) = @time_zone = time_zone
|
|
142
|
+
|
|
143
|
+
def cast(value)
|
|
144
|
+
return value if value.is_a?(Time)
|
|
145
|
+
|
|
146
|
+
string = value.to_s
|
|
147
|
+
instant = if (match = WIRE_FORMAT.match(string))
|
|
148
|
+
time_from_wall_clock(wall_clock_parts(match))
|
|
149
|
+
else
|
|
150
|
+
zone.parse(string).utc
|
|
151
|
+
end
|
|
152
|
+
ActiveRecord.default_timezone == :local ? instant.getlocal : instant
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def time_from_wall_clock(parts)
|
|
158
|
+
found = zone
|
|
159
|
+
found.name == "UTC" ? ::Time.utc(*parts) : found.local(*parts).utc
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def wall_clock_parts(match)
|
|
163
|
+
seconds = match[6].to_i
|
|
164
|
+
seconds += Rational(match[7].to_i, 10**match[7].length) if match[7]
|
|
165
|
+
[match[1].to_i, match[2].to_i, match[3].to_i, match[4].to_i, match[5].to_i, seconds]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def zone
|
|
169
|
+
found = @time_zone ? ActiveSupport::TimeZone[@time_zone] : default_zone
|
|
170
|
+
raise ArgumentError, "unknown time zone #{@time_zone.inspect}" if @time_zone && found.nil?
|
|
171
|
+
|
|
172
|
+
found
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def default_zone
|
|
176
|
+
if ActiveRecord.default_timezone == :local && ::Time.zone
|
|
177
|
+
::Time.zone
|
|
178
|
+
else
|
|
179
|
+
ActiveSupport::TimeZone["UTC"]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
class IpCaster
|
|
185
|
+
include Singleton
|
|
186
|
+
|
|
187
|
+
def cast(value) = value.is_a?(IPAddr) ? value : IPAddr.new(value.to_s)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
class JsonCaster
|
|
191
|
+
include Singleton
|
|
192
|
+
|
|
193
|
+
def cast(value)
|
|
194
|
+
case value
|
|
195
|
+
when String then JSON.parse(value)
|
|
196
|
+
else value
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
class ArrayCaster
|
|
202
|
+
def initialize(inner) = @inner = inner
|
|
203
|
+
def cast(value) = Array(value).map { |item| @inner.cast(item) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
class MapCaster
|
|
207
|
+
def initialize(key_caster, value_caster)
|
|
208
|
+
@key_caster = key_caster
|
|
209
|
+
@value_caster = value_caster
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def cast(value)
|
|
213
|
+
value.to_h { |key, item| [@key_caster.cast(key), @value_caster.cast(item)] }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
class PositionalTupleCaster
|
|
218
|
+
def initialize(casters) = @casters = casters
|
|
219
|
+
|
|
220
|
+
def cast(value)
|
|
221
|
+
Array(value).each_with_index.map { |item, index| @casters.fetch(index).cast(item) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
class NamedTupleCaster
|
|
226
|
+
def initialize(casters) = @casters = casters
|
|
227
|
+
|
|
228
|
+
def cast(value)
|
|
229
|
+
value.to_h do |key, item|
|
|
230
|
+
name = key.to_s
|
|
231
|
+
[name, @casters.fetch(name).cast(item)]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/abstract_adapter"
|
|
4
|
+
require "active_record/connection_adapters/clickhouse/database_statements"
|
|
5
|
+
require "active_record/connection_adapters/clickhouse/error_translation"
|
|
6
|
+
require "active_record/connection_adapters/clickhouse/http_connection"
|
|
7
|
+
require "active_record/connection_adapters/clickhouse/querying"
|
|
8
|
+
require "active_record/connection_adapters/clickhouse/quoting"
|
|
9
|
+
require "active_record/connection_adapters/clickhouse/row_binary"
|
|
10
|
+
require "active_record/connection_adapters/clickhouse/schema_definitions"
|
|
11
|
+
require "active_record/connection_adapters/clickhouse/schema_dumper"
|
|
12
|
+
require "active_record/connection_adapters/clickhouse/schema_statements"
|
|
13
|
+
require "active_record/connection_adapters/clickhouse/type_parser"
|
|
14
|
+
require "active_record/connection_adapters/clickhouse/types"
|
|
15
|
+
require "arel/visitors/clickhouse"
|
|
16
|
+
|
|
17
|
+
module ActiveRecord
|
|
18
|
+
module ConnectionAdapters
|
|
19
|
+
class ClickHouseAdapter < AbstractAdapter
|
|
20
|
+
ADAPTER_NAME = "ClickHouse"
|
|
21
|
+
|
|
22
|
+
include ClickHouse::Quoting
|
|
23
|
+
include ClickHouse::DatabaseStatements
|
|
24
|
+
include ClickHouse::SchemaStatements
|
|
25
|
+
include ClickHouse::ErrorTranslation
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def new_client(config)
|
|
29
|
+
ClickHouse::HTTPConnection.new(config)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
CONNECTION_PARAMETER_KEYS = %i[
|
|
34
|
+
host port username password database ssl ssl_verify select_format
|
|
35
|
+
connect_timeout read_timeout write_timeout
|
|
36
|
+
mutations_sync compression join_use_nulls async_insert wait_for_async_insert
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(...)
|
|
40
|
+
super
|
|
41
|
+
|
|
42
|
+
@connection_parameters =
|
|
43
|
+
{ host: "localhost", port: 8123 }.merge(@config.slice(*CONNECTION_PARAMETER_KEYS)).compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def active?
|
|
47
|
+
@lock.synchronize { @raw_connection&.ping } || false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# With cluster: configured, schema DDL is stamped ON CLUSTER so every replica
|
|
51
|
+
# runs it through the distributed DDL queue.
|
|
52
|
+
def cluster = @config[:cluster]
|
|
53
|
+
|
|
54
|
+
def on_cluster_clause
|
|
55
|
+
cluster ? " ON CLUSTER #{quote_table_name(cluster)}" : ""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def disconnect!
|
|
59
|
+
super
|
|
60
|
+
@raw_connection&.close
|
|
61
|
+
@raw_connection = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def supports_explain? = true
|
|
65
|
+
|
|
66
|
+
# DateTime64(P) is native precision; claiming it makes the DSL apply Rails'
|
|
67
|
+
# default microsecond precision (6) to datetime columns, like other adapters.
|
|
68
|
+
def supports_datetime_with_precision? = true
|
|
69
|
+
|
|
70
|
+
# Data-skipping indexes are INDEX clauses inside CREATE TABLE, not statements.
|
|
71
|
+
def supports_indexes_in_create? = true
|
|
72
|
+
|
|
73
|
+
# insert_all implies skip-duplicates; without unique constraints nothing can
|
|
74
|
+
# conflict, so the semantics hold vacuously and the INSERT goes through plain.
|
|
75
|
+
# Upsert still raises upstream (supports_insert_on_duplicate_update? is false).
|
|
76
|
+
def supports_insert_on_duplicate_skip? = true
|
|
77
|
+
|
|
78
|
+
def build_insert_sql(insert) = "INSERT #{insert.into} #{insert.values_list}" # :nodoc:
|
|
79
|
+
|
|
80
|
+
NATIVE_DATABASE_TYPES = {
|
|
81
|
+
string: { name: "String" },
|
|
82
|
+
text: { name: "String" },
|
|
83
|
+
integer: { name: "Int32", limit: 4 },
|
|
84
|
+
bigint: { name: "Int64", limit: 8 },
|
|
85
|
+
float: { name: "Float64" },
|
|
86
|
+
decimal: { name: "Decimal" },
|
|
87
|
+
datetime: { name: "DateTime64" },
|
|
88
|
+
date: { name: "Date32" },
|
|
89
|
+
boolean: { name: "Bool" },
|
|
90
|
+
uuid: { name: "UUID" },
|
|
91
|
+
json: { name: "JSON" }
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
def native_database_types = NATIVE_DATABASE_TYPES
|
|
95
|
+
|
|
96
|
+
# Column types the dumper can't map to AR symbols (Array, Map, Tuple, ...) are
|
|
97
|
+
# dumped verbatim, so every introspected type is valid by construction.
|
|
98
|
+
def valid_type?(_type) = true
|
|
99
|
+
|
|
100
|
+
def create_schema_dumper(options) # :nodoc:
|
|
101
|
+
ClickHouse::SchemaDumper.create(self, options)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def get_database_version # :nodoc:
|
|
105
|
+
Version.new(query_value("SELECT version()", "SCHEMA"))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def connect
|
|
111
|
+
@raw_connection = self.class.new_client(@connection_parameters)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reconnect
|
|
115
|
+
@raw_connection&.close
|
|
116
|
+
connect
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# No server-side prepared statements; `true` selects the Arel Bind collector so we can
|
|
120
|
+
# rewrite `?` into ClickHouse `{pN:Type}` HTTP query parameters in perform_query.
|
|
121
|
+
def default_prepared_statements = true
|
|
122
|
+
|
|
123
|
+
def arel_visitor = Arel::Visitors::ClickHouse.new(self)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/clickhouse/http_connection"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Tasks
|
|
7
|
+
# db:create/db:drop/db:purge and structure dump/load for ClickHouse. Database-level
|
|
8
|
+
# DDL runs over a raw HTTP connection without a database selected, since the
|
|
9
|
+
# configured database may not exist yet.
|
|
10
|
+
class ClickHouseDatabaseTasks < AbstractTasks
|
|
11
|
+
DATABASE_ALREADY_EXISTS_CODE = 82
|
|
12
|
+
UNKNOWN_DATABASE_CODE = 81
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
server_execute("CREATE DATABASE #{quoted_database}")
|
|
16
|
+
rescue ConnectionAdapters::ClickHouse::HTTPConnection::ExecutionError => e
|
|
17
|
+
raise DatabaseAlreadyExists if e.code == DATABASE_ALREADY_EXISTS_CODE
|
|
18
|
+
|
|
19
|
+
raise
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def drop
|
|
23
|
+
server_execute("DROP DATABASE #{quoted_database}")
|
|
24
|
+
rescue ConnectionAdapters::ClickHouse::HTTPConnection::ExecutionError => e
|
|
25
|
+
raise NoDatabaseError, e.message if e.code == UNKNOWN_DATABASE_CODE
|
|
26
|
+
|
|
27
|
+
raise
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def purge
|
|
31
|
+
drop
|
|
32
|
+
rescue NoDatabaseError
|
|
33
|
+
nil
|
|
34
|
+
ensure
|
|
35
|
+
create
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def structure_dump(filename, _flags)
|
|
39
|
+
establish_connection
|
|
40
|
+
statements = dumpable_data_sources.map do |name|
|
|
41
|
+
connection.select_value("SHOW CREATE TABLE #{connection.quote_table_name(name)}")
|
|
42
|
+
end
|
|
43
|
+
File.write(filename, statements.map { |statement| "#{statement};\n\n" }.join)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Statements are separated by ";\n\n" exactly as structure_dump writes them —
|
|
47
|
+
# ClickHouse HTTP accepts one statement per request, so the file is replayed.
|
|
48
|
+
def structure_load(filename, _flags)
|
|
49
|
+
establish_connection
|
|
50
|
+
File.read(filename).split(";\n\n").each do |statement|
|
|
51
|
+
statement = statement.strip
|
|
52
|
+
connection.execute(reinject_dictionary_credentials(statement)) unless statement.empty?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# SHOW CREATE masks dictionary source passwords as [HIDDEN] (and the dumped USER
|
|
59
|
+
# belongs to the dumping environment), so loading swaps in this connection's
|
|
60
|
+
# credentials — the same ones create_dictionary would inject.
|
|
61
|
+
def reinject_dictionary_credentials(statement)
|
|
62
|
+
return statement unless statement.match?(/\ACREATE DICTIONARY/i)
|
|
63
|
+
|
|
64
|
+
quote = ->(value) { "'#{value.to_s.gsub("\\", "\\\\\\\\").gsub("'", "\\\\'")}'" }
|
|
65
|
+
statement
|
|
66
|
+
.gsub(/\bUSER '(?:[^'\\]|\\.)*'/) { "USER #{quote.call(configuration_hash[:username])}" }
|
|
67
|
+
.gsub(/\bPASSWORD '(?:[^'\\]|\\.)*'/) { "PASSWORD #{quote.call(configuration_hash[:password])}" }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ignore_tables entries may be String, Regexp, or Proc — === is the contract
|
|
71
|
+
# Rails' own SchemaDumper uses to match them.
|
|
72
|
+
def dumpable_data_sources
|
|
73
|
+
ignored = ActiveRecord::SchemaDumper.ignore_tables
|
|
74
|
+
connection.data_sources.sort.reject { |name| ignored.any? { |pattern| pattern === name } } # rubocop:disable Style/CaseEquality
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def quoted_database
|
|
78
|
+
"`#{db_config.database.to_s.gsub("`", "``")}`"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def server_execute(sql)
|
|
82
|
+
client = ConnectionAdapters::ClickHouse::HTTPConnection.new(
|
|
83
|
+
configuration_hash.except(:adapter, :database)
|
|
84
|
+
)
|
|
85
|
+
client.execute(sql)
|
|
86
|
+
ensure
|
|
87
|
+
client&.close
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_record/connection_adapters"
|
|
5
|
+
require "active_record/connection_adapters/clickhouse/gem_version"
|
|
6
|
+
# Eager so models can `include ...ClickHouse::Querying` at boot, before (or without)
|
|
7
|
+
# any ClickHouse connection loading the adapter — e.g. app code shared across envs
|
|
8
|
+
# where the sink is only wired in production.
|
|
9
|
+
require "active_record/connection_adapters/clickhouse/querying"
|
|
10
|
+
require "active_record/tasks/clickhouse_database_tasks"
|
|
11
|
+
|
|
12
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
13
|
+
"clickhouse",
|
|
14
|
+
"ActiveRecord::ConnectionAdapters::ClickHouseAdapter",
|
|
15
|
+
"active_record/connection_adapters/clickhouse_adapter"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task(
|
|
19
|
+
/clickhouse/,
|
|
20
|
+
"ActiveRecord::Tasks::ClickHouseDatabaseTasks"
|
|
21
|
+
)
|