clickhouse-activerecord 1.3.1 → 1.4.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 +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +3 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +96 -114
- data/lib/active_record/connection_adapters/clickhouse/statement/format_manager.rb +46 -0
- data/lib/active_record/connection_adapters/clickhouse/statement/response_processor.rb +99 -0
- data/lib/active_record/connection_adapters/clickhouse/statement.rb +30 -0
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +33 -25
- data/lib/arel/visitors/clickhouse.rb +7 -1
- data/lib/clickhouse-activerecord/schema_dumper.rb +3 -1
- data/lib/clickhouse-activerecord/tasks.rb +2 -2
- data/lib/clickhouse-activerecord/version.rb +1 -1
- data/lib/core_extensions/active_record/relation.rb +53 -10
- data/lib/core_extensions/arel/nodes/select_core.rb +7 -0
- data/lib/core_extensions/arel/nodes/select_statement.rb +4 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cd5f6e3d161c6a71abcbafb000b06a3bf0637c07f451bd410c06f9d25fe20901
|
4
|
+
data.tar.gz: e4ba690507249b3a5d9d1a975b5cc3c7d0bfa6018f5744b84f2a382be3bcd996
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3fdc2f20d8540d9599bf6281d19b8408022575d5a6648caaff55337e039bc6538c8da95004f9ab9eea74bd438c514d7907b25cd7ddd58d5f134fbc1ff77732eb
|
7
|
+
data.tar.gz: 45d9da6d58a53e614d2966bfcf0b7db575dce2e8afab6bfdc8cb0f4853ca977a6bfd15e43f767bcce934dbfea371921abc0215f0fdf304661961c58d499bde4a
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,48 @@
|
|
1
|
-
### Version 1.
|
1
|
+
### Version 1.4.0 (Sep 18, 2025)
|
2
|
+
|
3
|
+
* `CREATE OR REPLACE FUNCTION` in SQL schema dumps in #198
|
4
|
+
* Added shard config to handle replica path with shard in #201
|
5
|
+
* Add support for simple schema dumping in #203
|
6
|
+
* Unscope :final and :settings in #208
|
7
|
+
* Encapsulate format logic within Statement and helper classes in #162
|
8
|
+
|
9
|
+
### Version 1.3.1 (Feb 12, 2025)
|
10
|
+
|
11
|
+
* Restore replace database from dump schema table creation
|
12
|
+
|
13
|
+
### Version 1.3.0 (Jan 24, 2025)
|
14
|
+
|
15
|
+
* 🎉 Support for Rails 8.0 #189
|
16
|
+
* Restore multi-line table definitions in structure dumps #187
|
17
|
+
* Use a flag to track updates/deletes in SQL visitor #188
|
18
|
+
|
19
|
+
### Version 1.2.1 (Nov 18, 2024)
|
20
|
+
|
21
|
+
* Maintain primary key type specificity in #183
|
22
|
+
* Reliably sort functions, views, and materialized views in schema in #181
|
23
|
+
* Improve function dumps in #179
|
24
|
+
* Add support for integer limits in map type in #178
|
25
|
+
* Add support for `request_settings` in create_table
|
26
|
+
|
27
|
+
### Version 1.2.0 (Oct 23, 2024)
|
28
|
+
|
29
|
+
* Fix for function creation in `structure.sql` #166
|
30
|
+
* Add `group_by_grouping_sets` query method #161
|
31
|
+
* Add support for `CREATE FUNCTION` and `CREATE OR REPLACE FUNCTION`; the later in schema loading #146
|
32
|
+
* Add support for `LIMIT BY` clause #169
|
33
|
+
* Include column definitions in schema dump if the column name is not `id` #173
|
34
|
+
* Add blank line after create_function in schema #170
|
35
|
+
* Improve DB::Exception error handling #164
|
36
|
+
* SchemaDumper adds materialized view destination #159
|
37
|
+
* Add Array support to Map #158
|
38
|
+
* Add support codec compression parameter #135
|
39
|
+
|
40
|
+
### Version 1.1.3 (Sep 27, 2024)
|
41
|
+
|
42
|
+
* Fix schema dumper #163
|
43
|
+
|
44
|
+
### Version 1.1.2 (Aug 27, 2024)
|
45
|
+
|
2
46
|
* 🎉 Support for rails 7.2 #156
|
3
47
|
* Add method `views` for getting table `View` list in #152
|
4
48
|
* Add support for Map datatype in #144
|
data/README.md
CHANGED
@@ -33,6 +33,9 @@ default: &default
|
|
33
33
|
migrations_paths: db/clickhouse # optional, default: db/migrate_clickhouse
|
34
34
|
cluster_name: 'cluster_name' # optional for creating tables in cluster
|
35
35
|
replica_name: '{replica}' # replica macros name, optional for creating replicated tables
|
36
|
+
read_timeout: 300 # change network timeouts, by default 60 seconds
|
37
|
+
write_timeout: 300
|
38
|
+
keep_alive_timeout: 300
|
36
39
|
```
|
37
40
|
|
38
41
|
Alternatively if you wish to pass a custom `Net::HTTP` transport (or any other
|
@@ -6,22 +6,55 @@ module ActiveRecord
|
|
6
6
|
module ConnectionAdapters
|
7
7
|
module Clickhouse
|
8
8
|
module SchemaStatements
|
9
|
-
DEFAULT_RESPONSE_FORMAT = 'JSONCompactEachRowWithNamesAndTypes'.freeze
|
10
9
|
|
11
|
-
|
10
|
+
def with_settings(**settings)
|
11
|
+
@block_settings ||= {}
|
12
|
+
prev_settings = @block_settings
|
13
|
+
@block_settings = @block_settings.merge(settings)
|
14
|
+
yield
|
15
|
+
ensure
|
16
|
+
@block_settings = prev_settings
|
17
|
+
end
|
12
18
|
|
13
|
-
|
14
|
-
|
19
|
+
# Request a specific format for the duration of the provided block.
|
20
|
+
# Pass `nil` to explicitly send the SQL statement without a `FORMAT` clause.
|
21
|
+
# @param [String, nil] format
|
22
|
+
#
|
23
|
+
# @example Specify CSVWithNamesAndTypes format
|
24
|
+
# with_response_format('CSVWithNamesAndTypes') do
|
25
|
+
# Table.connection.execute('SELECT * FROM table')
|
26
|
+
# end
|
27
|
+
# # sends and executes "SELECT * FROM table FORMAT CSVWithNamesAndTypes"
|
28
|
+
#
|
29
|
+
# @example Specify no format
|
30
|
+
# with_response_format(nil) do
|
31
|
+
# Table.connection.execute('SELECT * FROM table')
|
32
|
+
# end
|
33
|
+
# # sends and executes "SELECT * FROM table"
|
34
|
+
def with_response_format(format)
|
35
|
+
prev_format = @response_format
|
36
|
+
@response_format = format
|
37
|
+
yield
|
38
|
+
ensure
|
39
|
+
@response_format = prev_format
|
40
|
+
end
|
41
|
+
|
42
|
+
def execute(sql, name = nil, format: @response_format, settings: {})
|
43
|
+
with_response_format(format) do
|
44
|
+
log(sql, [adapter_name, name].compact.join(' ')) do
|
45
|
+
raw_execute(sql, settings: settings)
|
46
|
+
end
|
47
|
+
end
|
15
48
|
end
|
16
49
|
|
17
|
-
def exec_insert(sql, name, _binds, _pk = nil, _sequence_name = nil, returning: nil)
|
18
|
-
new_sql = sql.
|
19
|
-
|
50
|
+
def exec_insert(sql, name = nil, _binds = [], _pk = nil, _sequence_name = nil, returning: nil)
|
51
|
+
new_sql = sql.sub(/ (DEFAULT )?VALUES/, " VALUES")
|
52
|
+
with_response_format(nil) { execute(new_sql, name) }
|
20
53
|
true
|
21
54
|
end
|
22
55
|
|
23
56
|
def internal_exec_query(sql, name = nil, binds = [], prepare: false, async: false, allow_retry: false)
|
24
|
-
result =
|
57
|
+
result = execute(sql, name)
|
25
58
|
columns = result['meta'].map { |m| m['name'] }
|
26
59
|
types = {}
|
27
60
|
result['meta'].each_with_index do |m, i|
|
@@ -37,24 +70,25 @@ module ActiveRecord
|
|
37
70
|
end
|
38
71
|
|
39
72
|
def exec_insert_all(sql, name)
|
40
|
-
|
73
|
+
with_response_format(nil) { execute(sql, name) }
|
41
74
|
true
|
42
75
|
end
|
43
76
|
|
44
77
|
# @link https://clickhouse.com/docs/en/sql-reference/statements/alter/update
|
45
|
-
def exec_update(
|
46
|
-
|
78
|
+
def exec_update(sql, name = nil, _binds = [])
|
79
|
+
execute(sql, name)
|
47
80
|
0
|
48
81
|
end
|
49
82
|
|
50
83
|
# @link https://clickhouse.com/docs/en/sql-reference/statements/delete
|
51
|
-
def exec_delete(
|
52
|
-
log(
|
53
|
-
|
84
|
+
def exec_delete(sql, name = nil, _binds = [])
|
85
|
+
log(sql, "#{adapter_name} #{name}") do
|
86
|
+
statement = Statement.new(sql, format: @response_format)
|
87
|
+
res = request(statement)
|
54
88
|
begin
|
55
89
|
data = JSON.parse(res.header['x-clickhouse-summary'])
|
56
90
|
data['result_rows'].to_i
|
57
|
-
rescue
|
91
|
+
rescue JSON::ParserError
|
58
92
|
0
|
59
93
|
end
|
60
94
|
end
|
@@ -85,7 +119,10 @@ module ActiveRecord
|
|
85
119
|
end
|
86
120
|
|
87
121
|
def show_create_function(function)
|
88
|
-
|
122
|
+
result = do_system_execute("SELECT create_query FROM system.functions WHERE origin = 'SQLUserDefined' AND name = '#{function}'")
|
123
|
+
return if result.nil?
|
124
|
+
|
125
|
+
result['data'].flatten.first.sub(/\ACREATE FUNCTION/, 'CREATE OR REPLACE FUNCTION')
|
89
126
|
end
|
90
127
|
|
91
128
|
def table_options(table)
|
@@ -110,18 +147,18 @@ module ActiveRecord
|
|
110
147
|
tables
|
111
148
|
end
|
112
149
|
|
113
|
-
def do_system_execute(sql, name = nil)
|
114
|
-
log_with_debug(sql,
|
115
|
-
|
116
|
-
process_response(res, DEFAULT_RESPONSE_FORMAT, sql)
|
150
|
+
def do_system_execute(sql, name = nil, except_params: [])
|
151
|
+
log_with_debug(sql, [adapter_name, name].compact.join(' ')) do
|
152
|
+
raw_execute(sql, except_params: except_params)
|
117
153
|
end
|
118
154
|
end
|
119
155
|
|
120
156
|
def do_execute(sql, name = nil, format: DEFAULT_RESPONSE_FORMAT, settings: {})
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
157
|
+
ActiveRecord.deprecator.warn(<<~MSG.squish)
|
158
|
+
`do_execute` is deprecated and will be removed in an upcoming release.
|
159
|
+
Please use `execute` instead.
|
160
|
+
MSG
|
161
|
+
execute(sql, name, format: format, settings: settings)
|
125
162
|
end
|
126
163
|
|
127
164
|
if ::ActiveRecord::version >= Gem::Version.new('7.2')
|
@@ -154,7 +191,7 @@ module ActiveRecord
|
|
154
191
|
if (duplicate = inserting.detect { |v| inserting.count(v) > 1 })
|
155
192
|
raise "Duplicate migration #{duplicate}. Please renumber your migrations to resolve the conflict."
|
156
193
|
end
|
157
|
-
|
194
|
+
execute(insert_versions_sql(inserting), nil, settings: {max_partitions_per_insert_block: [100, inserting.size].max})
|
158
195
|
end
|
159
196
|
end
|
160
197
|
|
@@ -168,56 +205,19 @@ module ActiveRecord
|
|
168
205
|
end
|
169
206
|
end
|
170
207
|
|
171
|
-
|
172
|
-
|
173
|
-
# Make HTTP request to ClickHouse server
|
174
|
-
# @param [String] sql
|
175
|
-
# @param [String, nil] format
|
176
|
-
# @param [Hash] settings
|
177
|
-
# @return [Net::HTTPResponse]
|
178
|
-
def request(sql, format = nil, settings = {})
|
179
|
-
formatted_sql = apply_format(sql, format)
|
180
|
-
request_params = @connection_config || {}
|
181
|
-
@lock.synchronize do
|
182
|
-
@connection.post("/?#{request_params.merge(settings).to_param}", formatted_sql, {
|
183
|
-
'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}",
|
184
|
-
'Content-Type' => 'application/x-www-form-urlencoded',
|
185
|
-
})
|
186
|
-
end
|
187
|
-
end
|
208
|
+
protected
|
188
209
|
|
189
|
-
def
|
190
|
-
|
191
|
-
|
210
|
+
def table_structure(table_name)
|
211
|
+
result = do_system_execute("DESCRIBE TABLE `#{table_name}`", table_name)
|
212
|
+
data = result['data']
|
192
213
|
|
193
|
-
|
194
|
-
case res.code.to_i
|
195
|
-
when 200
|
196
|
-
body = res.body
|
214
|
+
return data unless data.empty?
|
197
215
|
|
198
|
-
|
199
|
-
raise ActiveRecord::ActiveRecordError, "Response code: #{res.code}:\n#{res.body}#{sql ? "\nQuery: #{sql}" : ''}"
|
200
|
-
else
|
201
|
-
format_body_response(res.body, format)
|
202
|
-
end
|
203
|
-
else
|
204
|
-
case res.body
|
205
|
-
when /DB::Exception:.*\(UNKNOWN_DATABASE\)/
|
206
|
-
raise ActiveRecord::NoDatabaseError
|
207
|
-
when /DB::Exception:.*\(DATABASE_ALREADY_EXISTS\)/
|
208
|
-
raise ActiveRecord::DatabaseAlreadyExists
|
209
|
-
else
|
210
|
-
raise ActiveRecord::ActiveRecordError, "Response code: #{res.code}:\n#{res.body}"
|
211
|
-
end
|
212
|
-
end
|
213
|
-
rescue JSON::ParserError
|
214
|
-
res.body
|
216
|
+
raise ActiveRecord::StatementInvalid, "Could not find table '#{table_name}'"
|
215
217
|
end
|
218
|
+
alias column_definitions table_structure
|
216
219
|
|
217
|
-
|
218
|
-
return yield unless @debug
|
219
|
-
log(sql, "#{name} (system)") { yield }
|
220
|
-
end
|
220
|
+
private
|
221
221
|
|
222
222
|
def schema_creation
|
223
223
|
Clickhouse::SchemaCreation.new(self)
|
@@ -236,20 +236,6 @@ module ActiveRecord
|
|
236
236
|
Clickhouse::Column.new(field[0], default_value, type_metadata, field[1].include?('Nullable'), default_function, codec: field[5].presence)
|
237
237
|
end
|
238
238
|
|
239
|
-
protected
|
240
|
-
|
241
|
-
def table_structure(table_name)
|
242
|
-
result = do_system_execute("DESCRIBE TABLE `#{table_name}`", table_name)
|
243
|
-
data = result['data']
|
244
|
-
|
245
|
-
return data unless data.empty?
|
246
|
-
|
247
|
-
raise ActiveRecord::StatementInvalid, "Could not find table '#{table_name}'"
|
248
|
-
end
|
249
|
-
alias column_definitions table_structure
|
250
|
-
|
251
|
-
private
|
252
|
-
|
253
239
|
# Extracts the value from a PostgreSQL column default definition.
|
254
240
|
def extract_value_from_default(default_expression, default_type)
|
255
241
|
return nil if default_type != 'DEFAULT' || default_expression.blank?
|
@@ -269,42 +255,38 @@ module ActiveRecord
|
|
269
255
|
(%r{\w+\(.*\)} === default)
|
270
256
|
end
|
271
257
|
|
272
|
-
def
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
when 'JSONCompact'
|
277
|
-
format_from_json_compact(body)
|
278
|
-
when 'JSONCompactEachRowWithNamesAndTypes'
|
279
|
-
format_from_json_compact_each_row_with_names_and_types(body)
|
280
|
-
else
|
281
|
-
body
|
282
|
-
end
|
283
|
-
end
|
284
|
-
|
285
|
-
def format_from_json_compact(body)
|
286
|
-
parse_json_payload(body)
|
258
|
+
def raw_execute(sql, settings: {}, except_params: [])
|
259
|
+
statement = Statement.new(sql, format: @response_format)
|
260
|
+
statement.response = request(statement, settings: settings, except_params: except_params)
|
261
|
+
statement.processed_response
|
287
262
|
end
|
288
263
|
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
264
|
+
# Make HTTP request to ClickHouse server
|
265
|
+
# @param [ActiveRecord::ConnectionAdapters::Clickhouse::Statement] statement
|
266
|
+
# @param [Hash] settings
|
267
|
+
# @param [Array] except_params
|
268
|
+
# @return [Net::HTTPResponse]
|
269
|
+
def request(statement, settings: {}, except_params: [])
|
270
|
+
@lock.synchronize do
|
271
|
+
@connection.post("/?#{settings_params(settings, except: except_params)}",
|
272
|
+
statement.formatted_sql,
|
273
|
+
'Content-Type' => 'application/x-www-form-urlencoded',
|
274
|
+
'User-Agent' => ClickhouseAdapter::USER_AGENT)
|
298
275
|
end
|
276
|
+
end
|
299
277
|
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
}
|
278
|
+
def log_with_debug(sql, name = nil)
|
279
|
+
return yield unless @debug
|
280
|
+
log(sql, "#{name} (system)") { yield }
|
304
281
|
end
|
305
282
|
|
306
|
-
def
|
307
|
-
|
283
|
+
def settings_params(settings = {}, except: [])
|
284
|
+
request_params = @connection_config || {}
|
285
|
+
block_settings = @block_settings || {}
|
286
|
+
request_params.merge(block_settings)
|
287
|
+
.merge(settings)
|
288
|
+
.except(*except)
|
289
|
+
.to_param
|
308
290
|
end
|
309
291
|
end
|
310
292
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module ConnectionAdapters
|
5
|
+
module Clickhouse
|
6
|
+
class Statement
|
7
|
+
class FormatManager
|
8
|
+
|
9
|
+
def initialize(sql, format:)
|
10
|
+
@sql = sql.strip
|
11
|
+
@format = format
|
12
|
+
end
|
13
|
+
|
14
|
+
def apply
|
15
|
+
return @sql if skip_format? || @format.blank?
|
16
|
+
|
17
|
+
"#{@sql} FORMAT #{@format}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def skip_format?
|
21
|
+
system_command? || schema_command? || format_specified? || delete?
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def system_command?
|
27
|
+
/\Asystem|\Aoptimize/i.match?(@sql)
|
28
|
+
end
|
29
|
+
|
30
|
+
def schema_command?
|
31
|
+
/\Acreate|\Aalter|\Adrop|\Arename/i.match?(@sql)
|
32
|
+
end
|
33
|
+
|
34
|
+
def format_specified?
|
35
|
+
/format [a-z]+\z/i.match?(@sql)
|
36
|
+
end
|
37
|
+
|
38
|
+
def delete?
|
39
|
+
/\Adelete from/i.match?(@sql)
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module ConnectionAdapters
|
5
|
+
module Clickhouse
|
6
|
+
class Statement
|
7
|
+
class ResponseProcessor
|
8
|
+
|
9
|
+
DB_EXCEPTION_REGEXP = /\ACode:\s+\d+\.\s+DB::Exception:/.freeze
|
10
|
+
|
11
|
+
def initialize(raw_response, format, sql)
|
12
|
+
@raw_response = raw_response
|
13
|
+
@body = raw_response.body
|
14
|
+
@format = format
|
15
|
+
@sql = sql
|
16
|
+
end
|
17
|
+
|
18
|
+
def process
|
19
|
+
if success?
|
20
|
+
process_successful_response
|
21
|
+
else
|
22
|
+
raise_database_error!
|
23
|
+
end
|
24
|
+
rescue JSON::ParserError
|
25
|
+
@body
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def success?
|
31
|
+
@raw_response.code.to_i == 200
|
32
|
+
end
|
33
|
+
|
34
|
+
def process_successful_response
|
35
|
+
raise_generic!(@sql) if @body.include?('DB::Exception') && @body.match?(DB_EXCEPTION_REGEXP)
|
36
|
+
|
37
|
+
format_body_response
|
38
|
+
end
|
39
|
+
|
40
|
+
def raise_generic!(sql = nil)
|
41
|
+
raise ActiveRecord::ActiveRecordError, "Response code: #{@raw_response.code}:\n#{@body}#{"\nQuery: #{sql}" if sql}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def format_body_response
|
45
|
+
return @body if @body.blank?
|
46
|
+
|
47
|
+
case @format
|
48
|
+
when 'JSONCompact'
|
49
|
+
format_from_json_compact(@body)
|
50
|
+
when 'JSONCompactEachRowWithNamesAndTypes'
|
51
|
+
format_from_json_compact_each_row_with_names_and_types(@body)
|
52
|
+
else
|
53
|
+
@body
|
54
|
+
end
|
55
|
+
rescue JSON::ParserError
|
56
|
+
@body
|
57
|
+
end
|
58
|
+
|
59
|
+
def format_from_json_compact(body)
|
60
|
+
parse_json_payload(body)
|
61
|
+
end
|
62
|
+
|
63
|
+
def format_from_json_compact_each_row_with_names_and_types(body)
|
64
|
+
rows = body.each_line.map { |row| parse_json_payload(row) }
|
65
|
+
names, types, *data = rows
|
66
|
+
|
67
|
+
meta = names.zip(types).map do |name, type|
|
68
|
+
{
|
69
|
+
'name' => name,
|
70
|
+
'type' => type
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
{
|
75
|
+
'meta' => meta,
|
76
|
+
'data' => data
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
def parse_json_payload(payload)
|
81
|
+
JSON.parse(payload, decimal_class: BigDecimal)
|
82
|
+
end
|
83
|
+
|
84
|
+
def raise_database_error!
|
85
|
+
case @body
|
86
|
+
when /DB::Exception:.*\(UNKNOWN_DATABASE\)/
|
87
|
+
raise ActiveRecord::NoDatabaseError
|
88
|
+
when /DB::Exception:.*\(DATABASE_ALREADY_EXISTS\)/
|
89
|
+
raise ActiveRecord::DatabaseAlreadyExists
|
90
|
+
else
|
91
|
+
raise_generic!
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record/connection_adapters/clickhouse/statement/format_manager'
|
4
|
+
require 'active_record/connection_adapters/clickhouse/statement/response_processor'
|
5
|
+
|
6
|
+
module ActiveRecord
|
7
|
+
module ConnectionAdapters
|
8
|
+
module Clickhouse
|
9
|
+
class Statement
|
10
|
+
|
11
|
+
attr_reader :format
|
12
|
+
attr_writer :response
|
13
|
+
|
14
|
+
def initialize(sql, format:)
|
15
|
+
@sql = sql
|
16
|
+
@format = format
|
17
|
+
end
|
18
|
+
|
19
|
+
def formatted_sql
|
20
|
+
@formatted_sql ||= FormatManager.new(@sql, format: @format).apply
|
21
|
+
end
|
22
|
+
|
23
|
+
def processed_response
|
24
|
+
ResponseProcessor.new(@response, @format, @sql).process
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -16,6 +16,7 @@ require 'active_record/connection_adapters/clickhouse/column'
|
|
16
16
|
require 'active_record/connection_adapters/clickhouse/quoting'
|
17
17
|
require 'active_record/connection_adapters/clickhouse/schema_creation'
|
18
18
|
require 'active_record/connection_adapters/clickhouse/schema_statements'
|
19
|
+
require 'active_record/connection_adapters/clickhouse/statement'
|
19
20
|
require 'active_record/connection_adapters/clickhouse/table_definition'
|
20
21
|
require 'net/http'
|
21
22
|
require 'openssl'
|
@@ -82,6 +83,8 @@ module ActiveRecord
|
|
82
83
|
include Clickhouse::Quoting
|
83
84
|
|
84
85
|
ADAPTER_NAME = 'Clickhouse'.freeze
|
86
|
+
DEFAULT_RESPONSE_FORMAT = 'JSONCompactEachRowWithNamesAndTypes'.freeze
|
87
|
+
USER_AGENT = "ClickHouse ActiveRecord #{ClickhouseActiverecord::VERSION}"
|
85
88
|
NATIVE_DATABASE_TYPES = {
|
86
89
|
string: { name: 'String' },
|
87
90
|
integer: { name: 'UInt32' },
|
@@ -137,6 +140,7 @@ module ActiveRecord
|
|
137
140
|
|
138
141
|
@connection_config = { user: @config[:username], password: @config[:password], database: @config[:database] }.compact
|
139
142
|
@debug = @config[:debug] || false
|
143
|
+
@response_format = @config[:format] || DEFAULT_RESPONSE_FORMAT
|
140
144
|
|
141
145
|
@prepared_statements = false
|
142
146
|
|
@@ -145,7 +149,7 @@ module ActiveRecord
|
|
145
149
|
|
146
150
|
# Return ClickHouse server version
|
147
151
|
def server_version
|
148
|
-
@server_version ||=
|
152
|
+
@server_version ||= select_value('SELECT version()')
|
149
153
|
end
|
150
154
|
|
151
155
|
# Savepoints are not supported, noop
|
@@ -310,10 +314,7 @@ module ActiveRecord
|
|
310
314
|
# Create a new ClickHouse database.
|
311
315
|
def create_database(name)
|
312
316
|
sql = apply_cluster "CREATE DATABASE #{quote_table_name(name)}"
|
313
|
-
|
314
|
-
res = @connection.post("/?#{@connection_config.except(:database).to_param}", sql)
|
315
|
-
process_response(res, DEFAULT_RESPONSE_FORMAT)
|
316
|
-
end
|
317
|
+
do_system_execute sql, adapter_name, except_params: [:database]
|
317
318
|
end
|
318
319
|
|
319
320
|
def create_view(table_name, request_settings: {}, **options)
|
@@ -326,7 +327,7 @@ module ActiveRecord
|
|
326
327
|
drop_table(table_name, options.merge(if_exists: true))
|
327
328
|
end
|
328
329
|
|
329
|
-
|
330
|
+
execute(schema_creation.accept(td), settings: request_settings)
|
330
331
|
end
|
331
332
|
|
332
333
|
def create_table(table_name, request_settings: {}, **options, &block)
|
@@ -343,7 +344,7 @@ module ActiveRecord
|
|
343
344
|
drop_table(table_name, options.merge(if_exists: true))
|
344
345
|
end
|
345
346
|
|
346
|
-
|
347
|
+
execute(schema_creation.accept(td), settings: request_settings)
|
347
348
|
|
348
349
|
if options[:with_distributed]
|
349
350
|
distributed_table_name = options.delete(:with_distributed)
|
@@ -358,16 +359,13 @@ module ActiveRecord
|
|
358
359
|
|
359
360
|
def create_function(name, body, **options)
|
360
361
|
fd = "CREATE#{' OR REPLACE' if options[:force]} FUNCTION #{apply_cluster(quote_table_name(name))} AS #{body}"
|
361
|
-
|
362
|
+
execute(fd)
|
362
363
|
end
|
363
364
|
|
364
365
|
# Drops a ClickHouse database.
|
365
366
|
def drop_database(name) #:nodoc:
|
366
367
|
sql = apply_cluster "DROP DATABASE IF EXISTS #{quote_table_name(name)}"
|
367
|
-
|
368
|
-
res = @connection.post("/?#{@connection_config.except(:database).to_param}", sql)
|
369
|
-
process_response(res, DEFAULT_RESPONSE_FORMAT)
|
370
|
-
end
|
368
|
+
do_system_execute sql, adapter_name, except_params: [:database]
|
371
369
|
end
|
372
370
|
|
373
371
|
def drop_functions
|
@@ -377,7 +375,7 @@ module ActiveRecord
|
|
377
375
|
end
|
378
376
|
|
379
377
|
def rename_table(table_name, new_name)
|
380
|
-
|
378
|
+
execute apply_cluster "RENAME TABLE #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}"
|
381
379
|
end
|
382
380
|
|
383
381
|
def drop_table(table_name, options = {}) # :nodoc:
|
@@ -387,7 +385,7 @@ module ActiveRecord
|
|
387
385
|
query = apply_cluster(query)
|
388
386
|
query = "#{query} SYNC" if options[:sync]
|
389
387
|
|
390
|
-
|
388
|
+
execute(query)
|
391
389
|
|
392
390
|
if options[:with_distributed]
|
393
391
|
distributed_table_name = options.delete(:with_distributed)
|
@@ -402,25 +400,19 @@ module ActiveRecord
|
|
402
400
|
query = apply_cluster(query)
|
403
401
|
query = "#{query} SYNC" if options[:sync]
|
404
402
|
|
405
|
-
|
403
|
+
execute(query)
|
406
404
|
end
|
407
405
|
|
408
406
|
def add_column(table_name, column_name, type, **options)
|
409
|
-
|
410
|
-
|
411
|
-
at = create_alter_table table_name
|
412
|
-
at.add_column(column_name, type, **options)
|
413
|
-
execute(schema_creation.accept(at), nil, settings: {wait_end_of_query: 1, send_progress_in_http_headers: 1})
|
407
|
+
with_settings(wait_end_of_query: 1, send_progress_in_http_headers: 1) { super }
|
414
408
|
end
|
415
409
|
|
416
410
|
def remove_column(table_name, column_name, type = nil, **options)
|
417
|
-
|
418
|
-
|
419
|
-
execute("ALTER TABLE #{quote_table_name(table_name)} #{remove_column_for_alter(table_name, column_name, type, **options)}", nil, settings: {wait_end_of_query: 1, send_progress_in_http_headers: 1})
|
411
|
+
with_settings(wait_end_of_query: 1, send_progress_in_http_headers: 1) { super }
|
420
412
|
end
|
421
413
|
|
422
414
|
def change_column(table_name, column_name, type, **options)
|
423
|
-
result =
|
415
|
+
result = execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_for_alter(table_name, column_name, type, **options)}", nil, settings: {wait_end_of_query: 1, send_progress_in_http_headers: 1})
|
424
416
|
raise "Error parse json response: #{result}" if result.presence && !result.is_a?(Hash)
|
425
417
|
end
|
426
418
|
|
@@ -479,6 +471,13 @@ module ActiveRecord
|
|
479
471
|
@config[:database]
|
480
472
|
end
|
481
473
|
|
474
|
+
# Returns the shard name from the configuration.
|
475
|
+
# This is used to identify the shard in replication paths when using both sharding and replication.
|
476
|
+
# Required when you have multiple shards with replication to ensure unique paths for each shard's replication metadata.
|
477
|
+
def shard
|
478
|
+
@config[:shard_name]
|
479
|
+
end
|
480
|
+
|
482
481
|
def use_default_replicated_merge_tree_params?
|
483
482
|
database_engine_atomic? && @config[:use_default_replicated_merge_tree_params]
|
484
483
|
end
|
@@ -487,8 +486,17 @@ module ActiveRecord
|
|
487
486
|
(replica || use_default_replicated_merge_tree_params?) && cluster
|
488
487
|
end
|
489
488
|
|
489
|
+
# Returns the path for replication metadata.
|
490
|
+
# When sharding is enabled (shard_name is set), the path includes the shard identifier
|
491
|
+
# to ensure unique paths for each shard's replication metadata.
|
492
|
+
# Format with sharding: /clickhouse/tables/{cluster}/{shard}/{database}.{table}
|
493
|
+
# Format without sharding: /clickhouse/tables/{cluster}/{database}.{table}
|
490
494
|
def replica_path(table)
|
491
|
-
|
495
|
+
if shard
|
496
|
+
"/clickhouse/tables/#{cluster}/#{shard}/#{@connection_config[:database]}.#{table}"
|
497
|
+
else
|
498
|
+
"/clickhouse/tables/#{cluster}/#{@connection_config[:database]}.#{table}"
|
499
|
+
end
|
492
500
|
end
|
493
501
|
|
494
502
|
def database_engine_atomic?
|
@@ -51,8 +51,14 @@ module Arel
|
|
51
51
|
end
|
52
52
|
|
53
53
|
def visit_Arel_Nodes_Final(o, collector)
|
54
|
-
visit o.expr, collector
|
54
|
+
visit o.expr.left, collector
|
55
55
|
collector << ' FINAL'
|
56
|
+
|
57
|
+
o.expr.right.each do |join|
|
58
|
+
collector << ' '
|
59
|
+
visit join, collector
|
60
|
+
end
|
61
|
+
|
56
62
|
collector
|
57
63
|
end
|
58
64
|
|
@@ -31,6 +31,8 @@ module ClickhouseActiverecord
|
|
31
31
|
|
32
32
|
def table(table, stream)
|
33
33
|
if table.match(/^\.inner/).nil?
|
34
|
+
sql= ""
|
35
|
+
simple ||= ENV['simple'] == 'true'
|
34
36
|
unless simple
|
35
37
|
stream.puts " # TABLE: #{table}"
|
36
38
|
sql = @connection.show_create_table(table)
|
@@ -126,7 +128,7 @@ module ClickhouseActiverecord
|
|
126
128
|
sql = @connection.show_create_function(function)
|
127
129
|
if sql
|
128
130
|
stream.puts " # SQL: #{sql}"
|
129
|
-
stream.puts " create_function \"#{function}\", \"#{sql.
|
131
|
+
stream.puts " create_function \"#{function}\", \"#{sql.sub(/\ACREATE(OR REPLACE)? FUNCTION .*? AS/, '').strip}\", force: true"
|
130
132
|
stream.puts
|
131
133
|
end
|
132
134
|
end
|
@@ -66,9 +66,9 @@ module ClickhouseActiverecord
|
|
66
66
|
if sql.gsub(/[a-z]/i, '').blank?
|
67
67
|
next
|
68
68
|
elsif sql =~ /^INSERT INTO/
|
69
|
-
connection.
|
69
|
+
connection.execute(sql, nil, format: nil)
|
70
70
|
elsif sql =~ /^CREATE .*?FUNCTION/
|
71
|
-
connection.
|
71
|
+
connection.execute(sql, nil, format: nil)
|
72
72
|
else
|
73
73
|
connection.execute(sql)
|
74
74
|
end
|
@@ -1,6 +1,11 @@
|
|
1
1
|
module CoreExtensions
|
2
2
|
module ActiveRecord
|
3
3
|
module Relation
|
4
|
+
|
5
|
+
def self.prepended(base)
|
6
|
+
base::VALID_UNSCOPING_VALUES << :final << :settings
|
7
|
+
end
|
8
|
+
|
4
9
|
def reverse_order!
|
5
10
|
return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter)
|
6
11
|
|
@@ -19,17 +24,39 @@ module CoreExtensions
|
|
19
24
|
#
|
20
25
|
# An <tt>ActiveRecord::ActiveRecordError</tt> will be raised if database not ClickHouse.
|
21
26
|
# @param [Hash] opts
|
27
|
+
|
28
|
+
|
29
|
+
# Specify settings to be used for this single query.
|
30
|
+
# For example:
|
31
|
+
#
|
32
|
+
# users = User.settings(use_skip_indexes: true).where(name: 'John')
|
33
|
+
# # SELECT "users".* FROM "users"
|
34
|
+
# # WHERE "users"."name" = 'John'
|
35
|
+
# # SETTINGS use_skip_indexes = 1
|
22
36
|
def settings(**opts)
|
23
37
|
spawn.settings!(**opts)
|
24
38
|
end
|
25
39
|
|
26
40
|
# @param [Hash] opts
|
27
41
|
def settings!(**opts)
|
28
|
-
check_command('SETTINGS')
|
29
|
-
|
42
|
+
check_command!('SETTINGS')
|
43
|
+
self.settings_values = settings_values.merge opts
|
30
44
|
self
|
31
45
|
end
|
32
46
|
|
47
|
+
def settings_values
|
48
|
+
@values.fetch(:settings, ::ActiveRecord::QueryMethods::FROZEN_EMPTY_HASH)
|
49
|
+
end
|
50
|
+
|
51
|
+
def settings_values=(value)
|
52
|
+
if ::ActiveRecord::version >= Gem::Version.new('7.2')
|
53
|
+
assert_modifiable!
|
54
|
+
else
|
55
|
+
assert_mutability!
|
56
|
+
end
|
57
|
+
@values[:settings] = value
|
58
|
+
end
|
59
|
+
|
33
60
|
# When FINAL is specified, ClickHouse fully merges the data before returning the result and thus performs all data transformations that happen during merges for the given table engine.
|
34
61
|
# For example:
|
35
62
|
#
|
@@ -37,16 +64,32 @@ module CoreExtensions
|
|
37
64
|
# # SELECT users.* FROM users FINAL
|
38
65
|
#
|
39
66
|
# An <tt>ActiveRecord::ActiveRecordError</tt> will be raised if database not ClickHouse.
|
40
|
-
|
41
|
-
|
67
|
+
#
|
68
|
+
# @param [Boolean] final
|
69
|
+
def final(final = true)
|
70
|
+
spawn.final!(final)
|
42
71
|
end
|
43
72
|
|
44
|
-
|
45
|
-
|
46
|
-
|
73
|
+
# @param [Boolean] final
|
74
|
+
def final!(final = true)
|
75
|
+
check_command!('FINAL')
|
76
|
+
self.final_value = final
|
47
77
|
self
|
48
78
|
end
|
49
79
|
|
80
|
+
def final_value=(value)
|
81
|
+
if ::ActiveRecord::version >= Gem::Version.new('7.2')
|
82
|
+
assert_modifiable!
|
83
|
+
else
|
84
|
+
assert_mutability!
|
85
|
+
end
|
86
|
+
@values[:final] = value
|
87
|
+
end
|
88
|
+
|
89
|
+
def final_value
|
90
|
+
@values.fetch(:final, nil)
|
91
|
+
end
|
92
|
+
|
50
93
|
# GROUPING SETS allows you to specify multiple groupings in the GROUP BY clause.
|
51
94
|
# Whereas GROUP BY CUBE generates all possible groupings, GROUP BY GROUPING SETS generates only the specified groupings.
|
52
95
|
# For example:
|
@@ -128,7 +171,7 @@ module CoreExtensions
|
|
128
171
|
|
129
172
|
private
|
130
173
|
|
131
|
-
def check_command(cmd)
|
174
|
+
def check_command!(cmd)
|
132
175
|
raise ::ActiveRecord::ActiveRecordError, cmd + ' is a ClickHouse specific query clause' unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter)
|
133
176
|
end
|
134
177
|
|
@@ -139,9 +182,9 @@ module CoreExtensions
|
|
139
182
|
arel = super(connection_or_aliases)
|
140
183
|
end
|
141
184
|
|
142
|
-
arel.final! if
|
185
|
+
arel.final! if final_value
|
143
186
|
arel.limit_by(*@values[:limit_by]) if @values[:limit_by].present?
|
144
|
-
arel.settings(
|
187
|
+
arel.settings(settings_values) unless settings_values.empty?
|
145
188
|
arel.using(@values[:using]) if @values[:using].present?
|
146
189
|
arel.windows(@values[:windows]) if @values[:windows].present?
|
147
190
|
|
@@ -10,6 +10,13 @@ module CoreExtensions
|
|
10
10
|
::Arel::Nodes::Final.new(super)
|
11
11
|
end
|
12
12
|
|
13
|
+
def hash
|
14
|
+
[
|
15
|
+
@source, @set_quantifier, @projections, @optimizer_hints,
|
16
|
+
@wheres, @groups, @havings, @windows, @comment, @final
|
17
|
+
].hash
|
18
|
+
end
|
19
|
+
|
13
20
|
def eql?(other)
|
14
21
|
super && final == other.final
|
15
22
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: clickhouse-activerecord
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sergey Odintsov
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-09-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -122,6 +122,9 @@ files:
|
|
122
122
|
- lib/active_record/connection_adapters/clickhouse/quoting.rb
|
123
123
|
- lib/active_record/connection_adapters/clickhouse/schema_creation.rb
|
124
124
|
- lib/active_record/connection_adapters/clickhouse/schema_statements.rb
|
125
|
+
- lib/active_record/connection_adapters/clickhouse/statement.rb
|
126
|
+
- lib/active_record/connection_adapters/clickhouse/statement/format_manager.rb
|
127
|
+
- lib/active_record/connection_adapters/clickhouse/statement/response_processor.rb
|
125
128
|
- lib/active_record/connection_adapters/clickhouse/table_definition.rb
|
126
129
|
- lib/active_record/connection_adapters/clickhouse_adapter.rb
|
127
130
|
- lib/arel/nodes/final.rb
|