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.
@@ -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
+ )