clickhouse-activerecord 1.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e47b553f60d06d69a8e1f7c70bbc495c32502f0ea75f3ede9c8ac2bafb063ee
4
- data.tar.gz: 42ec02c29478ae577fda1f1a726d003e925b2c36faa02fd6a92797a4e05f960c
3
+ metadata.gz: cd5f6e3d161c6a71abcbafb000b06a3bf0637c07f451bd410c06f9d25fe20901
4
+ data.tar.gz: e4ba690507249b3a5d9d1a975b5cc3c7d0bfa6018f5744b84f2a382be3bcd996
5
5
  SHA512:
6
- metadata.gz: 405f20e2c53352ae702b4b462165ccbf5306da2e72733d5c8f80588ba5392637e6b8d2c95d6d61cc9f777d0fc49e48a3578ee6db2fdb292668cb4ec0c52d0471
7
- data.tar.gz: 8b3bebc0c3672112c26889555d1eb0b280cbc331758cbf1d5d1733273983e10178929bec76fda6793a654d1ff2c0048b44a58eca533f3ec0a2e8481e7d928314
6
+ metadata.gz: 3fdc2f20d8540d9599bf6281d19b8408022575d5a6648caaff55337e039bc6538c8da95004f9ab9eea74bd438c514d7907b25cd7ddd58d5f134fbc1ff77732eb
7
+ data.tar.gz: 45d9da6d58a53e614d2966bfcf0b7db575dce2e8afab6bfdc8cb0f4853ca977a6bfd15e43f767bcce934dbfea371921abc0215f0fdf304661961c58d499bde4a
@@ -1,5 +1,3 @@
1
- version: '3.5'
2
-
3
1
  services:
4
2
  clickhouse1:
5
3
  image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-23.11-alpine}'
@@ -1,4 +1,3 @@
1
- version: '3.8'
2
1
  services:
3
2
  clickhouse:
4
3
  image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-23.11-alpine}'
data/CHANGELOG.md CHANGED
@@ -1,4 +1,48 @@
1
- ### Version 1.1.2 (Aug 27, 2024)
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
- DB_EXCEPTION_REGEXP = /\ACode:\s+\d+\.\s+DB::Exception:/.freeze
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
- def execute(sql, name = nil, settings: {})
14
- do_execute(sql, name, settings: settings)
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.dup.sub(/ (DEFAULT )?VALUES/, " VALUES")
19
- do_execute(new_sql, name, format: nil)
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 = do_execute(sql, name)
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
- do_execute(sql, name, format: nil)
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(_sql, _name = nil, _binds = [])
46
- do_execute(_sql, _name, format: nil)
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(_sql, _name = nil, _binds = [])
52
- log(_sql, "#{adapter_name} #{_name}") do
53
- res = request(_sql)
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 JSONError
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
- do_execute("SELECT create_query FROM system.functions WHERE origin = 'SQLUserDefined' AND name = '#{function}'", format: nil)
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, "#{adapter_name} #{name}") do
115
- res = request(sql, DEFAULT_RESPONSE_FORMAT)
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
- log(sql, "#{adapter_name} #{name}") do
122
- res = request(sql, format, settings)
123
- process_response(res, format, sql)
124
- end
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
- do_execute(insert_versions_sql(inserting), nil, format: nil, settings: {max_partitions_per_insert_block: [100, inserting.size].max})
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
- private
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 apply_format(sql, format)
190
- format ? "#{sql} FORMAT #{format}" : sql
191
- end
210
+ def table_structure(table_name)
211
+ result = do_system_execute("DESCRIBE TABLE `#{table_name}`", table_name)
212
+ data = result['data']
192
213
 
193
- def process_response(res, format, sql = nil)
194
- case res.code.to_i
195
- when 200
196
- body = res.body
214
+ return data unless data.empty?
197
215
 
198
- if body.include?("DB::Exception") && body.match?(DB_EXCEPTION_REGEXP)
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
- def log_with_debug(sql, name = nil)
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 format_body_response(body, format)
273
- return body if body.blank?
274
-
275
- case format
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
- def format_from_json_compact_each_row_with_names_and_types(body)
290
- rows = body.split("\n").map { |row| parse_json_payload(row) }
291
- names, types, *data = rows
292
-
293
- meta = names.zip(types).map do |name, type|
294
- {
295
- 'name' => name,
296
- 'type' => type
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
- 'meta' => meta,
302
- 'data' => data
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 parse_json_payload(payload)
307
- JSON.parse(payload, decimal_class: BigDecimal)
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 ||= do_system_execute('SELECT version()')['data'][0][0]
152
+ @server_version ||= select_value('SELECT version()')
149
153
  end
150
154
 
151
155
  # Savepoints are not supported, noop
@@ -303,17 +307,14 @@ module ActiveRecord
303
307
  # @option [Boolean] single_line
304
308
  # @return [String]
305
309
  def show_create_table(table, single_line: true)
306
- sql = do_system_execute("SHOW CREATE TABLE `#{table}`")['data'].try(:first).try(:first)
310
+ sql = do_system_execute("SHOW CREATE TABLE `#{table}`")['data'].try(:first).try(:first).gsub("#{@config[:database]}.", '')
307
311
  single_line ? sql.squish : sql
308
312
  end
309
313
 
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
- log_with_debug(sql, adapter_name) do
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
- do_execute(schema_creation.accept(td), format: nil, settings: request_settings)
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
- do_execute(schema_creation.accept(td), format: nil, settings: request_settings)
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
- do_execute(fd, format: nil)
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
- log_with_debug(sql, adapter_name) do
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
- do_execute apply_cluster "RENAME TABLE #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}"
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
- do_execute(query)
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
- do_execute(query, format: nil)
403
+ execute(query)
406
404
  end
407
405
 
408
406
  def add_column(table_name, column_name, type, **options)
409
- return if options[:if_not_exists] == true && column_exists?(table_name, column_name, type)
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
- return if options[:if_exists] == true && !column_exists?(table_name, column_name)
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 = do_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})
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
- "/clickhouse/tables/#{cluster}/#{@connection_config[:database]}.#{table}"
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.gsub(/^CREATE FUNCTION (.*?) AS/, '').strip}\", force: true"
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.do_execute(sql, nil, format: nil)
69
+ connection.execute(sql, nil, format: nil)
70
70
  elsif sql =~ /^CREATE .*?FUNCTION/
71
- connection.do_execute(sql, nil, format: nil)
71
+ connection.execute(sql, nil, format: nil)
72
72
  else
73
73
  connection.execute(sql)
74
74
  end
@@ -1,3 +1,3 @@
1
1
  module ClickhouseActiverecord
2
- VERSION = '1.3.0'
2
+ VERSION = '1.4.0'
3
3
  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
- @values[:settings] = (@values[:settings] || {}).merge opts
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
- def final
41
- spawn.final!
67
+ #
68
+ # @param [Boolean] final
69
+ def final(final = true)
70
+ spawn.final!(final)
42
71
  end
43
72
 
44
- def final!
45
- check_command('FINAL')
46
- @values[:final] = true
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 @values[:final].present?
185
+ arel.final! if final_value
143
186
  arel.limit_by(*@values[:limit_by]) if @values[:limit_by].present?
144
- arel.settings(@values[:settings]) if @values[:settings].present?
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
@@ -10,6 +10,10 @@ module CoreExtensions
10
10
  @settings = nil
11
11
  end
12
12
 
13
+ def hash
14
+ [@cores, @orders, @limit, @lock, @offset, @with, @settings].hash
15
+ end
16
+
13
17
  def eql?(other)
14
18
  super &&
15
19
  limit_by == other.limit_by &&
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.3.0
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-01-24 00:00:00.000000000 Z
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
@@ -166,7 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
166
169
  - !ruby/object:Gem::Version
167
170
  version: '0'
168
171
  requirements: []
169
- rubygems_version: 3.5.9
172
+ rubygems_version: 3.3.7
170
173
  signing_key:
171
174
  specification_version: 4
172
175
  summary: ClickHouse ActiveRecord