clickhouse-activerecord 1.3.1 → 1.5.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,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
@@ -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
- 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
@@ -39,7 +39,6 @@ module ClickhouseActiverecord
39
39
 
40
40
  # get all tables
41
41
  tables = connection.execute("SHOW TABLES FROM #{@configuration.database} WHERE name NOT LIKE '.inner_id.%'")['data'].flatten.map do |table|
42
- next if %w[schema_migrations ar_internal_metadata].include?(table)
43
42
  connection.show_create_table(table, single_line: false).gsub("#{@configuration.database}.", '')
44
43
  end.compact
45
44
 
@@ -66,9 +65,9 @@ module ClickhouseActiverecord
66
65
  if sql.gsub(/[a-z]/i, '').blank?
67
66
  next
68
67
  elsif sql =~ /^INSERT INTO/
69
- connection.do_execute(sql, nil, format: nil)
68
+ connection.execute(sql, nil, format: nil)
70
69
  elsif sql =~ /^CREATE .*?FUNCTION/
71
- connection.do_execute(sql, nil, format: nil)
70
+ connection.execute(sql, nil, format: nil)
72
71
  else
73
72
  connection.execute(sql)
74
73
  end
@@ -1,3 +1,3 @@
1
1
  module ClickhouseActiverecord
2
- VERSION = '1.3.1'
2
+ VERSION = '1.5.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,20 +171,22 @@ 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
 
135
178
  def build_arel(connection_or_aliases = nil, aliases = nil)
136
- if ::ActiveRecord::version >= Gem::Version.new('7.2')
179
+ requirement = Gem::Requirement.new('>= 7.2', '< 8.1')
180
+
181
+ if requirement.satisfied_by?(::ActiveRecord::version)
137
182
  arel = super
138
183
  else
139
184
  arel = super(connection_or_aliases)
140
185
  end
141
186
 
142
- arel.final! if @values[:final].present?
187
+ arel.final! if final_value
143
188
  arel.limit_by(*@values[:limit_by]) if @values[:limit_by].present?
144
- arel.settings(@values[:settings]) if @values[:settings].present?
189
+ arel.settings(settings_values) unless settings_values.empty?
145
190
  arel.using(@values[:using]) if @values[:using].present?
146
191
  arel.windows(@values[:windows]) if @values[:windows].present?
147
192
 
@@ -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,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clickhouse-activerecord
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Odintsov
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-02-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bundler
@@ -122,6 +121,9 @@ files:
122
121
  - lib/active_record/connection_adapters/clickhouse/quoting.rb
123
122
  - lib/active_record/connection_adapters/clickhouse/schema_creation.rb
124
123
  - lib/active_record/connection_adapters/clickhouse/schema_statements.rb
124
+ - lib/active_record/connection_adapters/clickhouse/statement.rb
125
+ - lib/active_record/connection_adapters/clickhouse/statement/format_manager.rb
126
+ - lib/active_record/connection_adapters/clickhouse/statement/response_processor.rb
125
127
  - lib/active_record/connection_adapters/clickhouse/table_definition.rb
126
128
  - lib/active_record/connection_adapters/clickhouse_adapter.rb
127
129
  - lib/arel/nodes/final.rb
@@ -146,12 +148,10 @@ files:
146
148
  - lib/core_extensions/arel/select_manager.rb
147
149
  - lib/core_extensions/arel/table.rb
148
150
  - lib/generators/clickhouse_migration_generator.rb
149
- - lib/tasks/clickhouse.rake
150
151
  homepage: https://github.com/pnixx/clickhouse-activerecord
151
152
  licenses:
152
153
  - MIT
153
154
  metadata: {}
154
- post_install_message:
155
155
  rdoc_options: []
156
156
  require_paths:
157
157
  - lib
@@ -166,8 +166,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
166
166
  - !ruby/object:Gem::Version
167
167
  version: '0'
168
168
  requirements: []
169
- rubygems_version: 3.3.7
170
- signing_key:
169
+ rubygems_version: 3.6.9
171
170
  specification_version: 4
172
171
  summary: ClickHouse ActiveRecord
173
172
  test_files: []