From 3affafa1352e8d16773cbc19b95ed407c8d26ae6 Mon Sep 17 00:00:00 2001 From: Benjamin Curtis Date: Tue, 30 Jan 2024 23:33:07 -0800 Subject: [PATCH 01/21] Use ILIKE for model.arel_table[:column]#matches (#115) Just like Postgres does :) --- lib/arel/visitors/clickhouse.rb | 10 ++++++++++ spec/cases/model_spec.rb | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lib/arel/visitors/clickhouse.rb b/lib/arel/visitors/clickhouse.rb index e9a180aa..f39942d4 100644 --- a/lib/arel/visitors/clickhouse.rb +++ b/lib/arel/visitors/clickhouse.rb @@ -53,6 +53,16 @@ def visit_Arel_Nodes_Using o, collector collector end + def visit_Arel_Nodes_Matches(o, collector) + op = o.case_sensitive ? " LIKE " : " ILIKE " + infix_value o, collector, op + end + + def visit_Arel_Nodes_DoesNotMatch(o, collector) + op = o.case_sensitive ? " NOT LIKE " : " NOT ILIKE " + infix_value o, collector, op + end + def sanitize_as_setting_value(value) if value == :default 'DEFAULT' diff --git a/spec/cases/model_spec.rb b/spec/cases/model_spec.rb index fafd3993..a48a3f97 100644 --- a/spec/cases/model_spec.rb +++ b/spec/cases/model_spec.rb @@ -143,6 +143,20 @@ class Model < ActiveRecord::Base end end + describe 'arel predicates' do + describe '#matches' do + it 'uses ilike for case insensitive matches' do + sql = model.where(model.arel_table[:event_name].matches('some event')).to_sql + expect(sql).to eq("SELECT sample.* FROM sample WHERE sample.event_name ILIKE 'some event'") + end + + it 'uses like for case sensitive matches' do + sql = model.where(model.arel_table[:event_name].matches('some event', nil, true)).to_sql + expect(sql).to eq("SELECT sample.* FROM sample WHERE sample.event_name LIKE 'some event'") + end + end + end + describe 'DateTime64 create' do it 'create a new record' do time = DateTime.parse('2023-07-21 08:00:00.123') From 86ee62557c5eadf225ed067ea591b371f766cd35 Mon Sep 17 00:00:00 2001 From: nixx Date: Thu, 1 Feb 2024 12:33:55 +0300 Subject: [PATCH 02/21] refactoring connection config --- .../clickhouse/schema_statements.rb | 4 +-- .../connection_adapters/clickhouse_adapter.rb | 26 +++++++++---------- .../active_record/internal_metadata.rb | 2 +- .../active_record/schema_migration.rb | 2 +- spec/cases/model_spec.rb | 3 +++ .../1_create_actions_table.rb | 1 + 6 files changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index 8027e8b9..2565dcb3 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -64,7 +64,7 @@ def data_sources def do_system_execute(sql, name = nil) log_with_debug(sql, "#{adapter_name} #{name}") do - res = @connection.post("/?#{@config.to_param}", "#{sql} FORMAT JSONCompact", 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") + res = @connection.post("/?#{@connection_config.to_param}", "#{sql} FORMAT JSONCompact", 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") process_response(res) end @@ -73,7 +73,7 @@ def do_system_execute(sql, name = nil) def do_execute(sql, name = nil, format: 'JSONCompact', settings: {}) log(sql, "#{adapter_name} #{name}") do formatted_sql = apply_format(sql, format) - request_params = @config || {} + request_params = @connection_config || {} res = @connection.post("/?#{request_params.merge(settings).to_param}", formatted_sql, 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") process_response(res) diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index f8cb1f56..d90ded3f 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -45,7 +45,7 @@ def clickhouse_connection(config) raise ArgumentError, 'No database specified. Missing argument: database.' end - ConnectionAdapters::ClickhouseAdapter.new(logger, connection, { user: config[:username], password: config[:password], database: database }.compact, config) + ConnectionAdapters::ClickhouseAdapter.new(logger, connection, config) end end end @@ -120,12 +120,12 @@ class ClickhouseAdapter < AbstractAdapter include Clickhouse::SchemaStatements # Initializes and connects a Clickhouse adapter. - def initialize(logger, connection_parameters, config, full_config) + def initialize(logger, connection_parameters, config) super(nil, logger) @connection_parameters = connection_parameters + @connection_config = { user: config[:username], password: config[:password], database: config[:database] }.compact + @debug = config[:debug] || false @config = config - @debug = full_config[:debug] || false - @full_config = full_config @prepared_statements = false @@ -133,7 +133,7 @@ def initialize(logger, connection_parameters, config, full_config) end def migrations_paths - @full_config[:migrations_paths] || 'db/migrate_clickhouse' + @config[:migrations_paths] || 'db/migrate_clickhouse' end def arel_visitor # :nodoc: @@ -266,7 +266,7 @@ def show_create_table(table) def create_database(name) sql = apply_cluster "CREATE DATABASE #{quote_table_name(name)}" log_with_debug(sql, adapter_name) do - res = @connection.post("/?#{@config.except(:database).to_param}", sql) + res = @connection.post("/?#{@connection_config.except(:database).to_param}", sql) process_response(res) end end @@ -302,7 +302,7 @@ def create_table(table_name, **options, &block) raise 'Set a cluster' unless cluster distributed_options = - "Distributed(#{cluster}, #{@config[:database]}, #{table_name}, #{sharding_key})" + "Distributed(#{cluster}, #{@connection_config[:database]}, #{table_name}, #{sharding_key})" create_table(distributed_table_name, **options.merge(options: distributed_options), &block) end end @@ -311,7 +311,7 @@ def create_table(table_name, **options, &block) def drop_database(name) #:nodoc: sql = apply_cluster "DROP DATABASE IF EXISTS #{quote_table_name(name)}" log_with_debug(sql, adapter_name) do - res = @connection.post("/?#{@config.except(:database).to_param}", sql) + res = @connection.post("/?#{@connection_config.except(:database).to_param}", sql) process_response(res) end end @@ -365,15 +365,15 @@ def change_column_default(table_name, column_name, default) end def cluster - @full_config[:cluster_name] + @config[:cluster_name] end def replica - @full_config[:replica_name] + @config[:replica_name] end def use_default_replicated_merge_tree_params? - database_engine_atomic? && @full_config[:use_default_replicated_merge_tree_params] + database_engine_atomic? && @config[:use_default_replicated_merge_tree_params] end def use_replica? @@ -381,11 +381,11 @@ def use_replica? end def replica_path(table) - "/clickhouse/tables/#{cluster}/#{@config[:database]}.#{table}" + "/clickhouse/tables/#{cluster}/#{@connection_config[:database]}.#{table}" end def database_engine_atomic? - current_database_engine = "select engine from system.databases where name = '#{@config[:database]}'" + current_database_engine = "select engine from system.databases where name = '#{@connection_config[:database]}'" res = select_one(current_database_engine) res['engine'] == 'Atomic' if res end diff --git a/lib/core_extensions/active_record/internal_metadata.rb b/lib/core_extensions/active_record/internal_metadata.rb index fb7f4d4f..79f41b79 100644 --- a/lib/core_extensions/active_record/internal_metadata.rb +++ b/lib/core_extensions/active_record/internal_metadata.rb @@ -24,7 +24,7 @@ def create_table options: connection.adapter_name.downcase == 'clickhouse' ? 'ReplacingMergeTree(created_at) PARTITION BY key ORDER BY key' : '', if_not_exists: true } - full_config = connection.instance_variable_get(:@full_config) || {} + full_config = connection.instance_variable_get(:@config) || {} if full_config[:distributed_service_tables] table_options.merge!(with_distributed: table_name, sharding_key: 'cityHash64(created_at)') diff --git a/lib/core_extensions/active_record/schema_migration.rb b/lib/core_extensions/active_record/schema_migration.rb index 3ff978e2..a6de4ffb 100644 --- a/lib/core_extensions/active_record/schema_migration.rb +++ b/lib/core_extensions/active_record/schema_migration.rb @@ -12,7 +12,7 @@ def create_table table_options = { id: false, options: 'ReplacingMergeTree(ver) ORDER BY (version)', if_not_exists: true } - full_config = connection.instance_variable_get(:@full_config) || {} + full_config = connection.instance_variable_get(:@config) || {} if full_config[:distributed_service_tables] table_options.merge!(with_distributed: table_name, sharding_key: 'cityHash64(version)') diff --git a/spec/cases/model_spec.rb b/spec/cases/model_spec.rb index a48a3f97..2a9869a1 100644 --- a/spec/cases/model_spec.rb +++ b/spec/cases/model_spec.rb @@ -200,6 +200,7 @@ class Model < ActiveRecord::Base model.create!( array_datetime: [1.day.ago, Time.now, '2022-12-06 15:22:49'], array_string: %w[asdf jkl], + array_int: [1, 2], date: date ) }.to change { model.count } @@ -208,6 +209,8 @@ class Model < ActiveRecord::Base expect(event.array_datetime[0].is_a?(DateTime)).to be_truthy expect(event.array_string[0].is_a?(String)).to be_truthy expect(event.array_string).to eq(%w[asdf jkl]) + expect(event.array_int.is_a?(Array)).to be_truthy + expect(event.array_int).to eq([1, 2]) end it 'get record' do diff --git a/spec/fixtures/migrations/add_array_datetime/1_create_actions_table.rb b/spec/fixtures/migrations/add_array_datetime/1_create_actions_table.rb index fa1d985b..7a1cd7f2 100644 --- a/spec/fixtures/migrations/add_array_datetime/1_create_actions_table.rb +++ b/spec/fixtures/migrations/add_array_datetime/1_create_actions_table.rb @@ -5,6 +5,7 @@ def up create_table :actions, options: 'MergeTree ORDER BY date', force: true do |t| t.datetime :array_datetime, null: false, array: true t.string :array_string, null: false, array: true + t.integer :array_int, null: false, array: true t.date :date, null: false end end From 7826020de4314742525b647714631373035e58ac Mon Sep 17 00:00:00 2001 From: nixx Date: Fri, 2 Feb 2024 13:59:18 +0300 Subject: [PATCH 03/21] fix `insert_all` array column --- .../clickhouse/schema_statements.rb | 10 ++++++++++ lib/clickhouse-activerecord/version.rb | 2 +- spec/cases/model_spec.rb | 11 +++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index 2565dcb3..86229510 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -100,6 +100,16 @@ def assume_migrated_upto_version(version, migrations_paths = nil) end end + # Fix insert_all method + # https://github.com/PNixx/clickhouse-activerecord/issues/71#issuecomment-1923244983 + def with_yaml_fallback(value) # :nodoc: + if value.is_a?(Array) + value + else + super + end + end + private def apply_format(sql, format) diff --git a/lib/clickhouse-activerecord/version.rb b/lib/clickhouse-activerecord/version.rb index f7d40eb2..3d160dec 100644 --- a/lib/clickhouse-activerecord/version.rb +++ b/lib/clickhouse-activerecord/version.rb @@ -1,3 +1,3 @@ module ClickhouseActiverecord - VERSION = '1.0.3' + VERSION = '1.0.4' end diff --git a/spec/cases/model_spec.rb b/spec/cases/model_spec.rb index 2a9869a1..39e8a952 100644 --- a/spec/cases/model_spec.rb +++ b/spec/cases/model_spec.rb @@ -213,6 +213,17 @@ class Model < ActiveRecord::Base expect(event.array_int).to eq([1, 2]) end + it 'create with insert all' do + expect { + model.insert_all([{ + array_datetime: [1.day.ago, Time.now, '2022-12-06 15:22:49'], + array_string: %w[asdf jkl], + array_int: [1, 2], + date: date + }]) + }.to change { model.count } + end + it 'get record' do model.connection.insert("INSERT INTO #{model.table_name} (id, array_datetime, date) VALUES (1, '[''2022-12-06 15:22:49'',''2022-12-05 15:22:49'']', '2022-12-06')") expect(model.count).to eq(1) From 1b34a9594496e4294b89830c67c6ee35b0273642 Mon Sep 17 00:00:00 2001 From: nixx Date: Tue, 13 Feb 2024 21:06:00 +0300 Subject: [PATCH 04/21] add spec for do_execute method before merge #116 --- spec/cases/model_spec.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec/cases/model_spec.rb b/spec/cases/model_spec.rb index 39e8a952..38c0420d 100644 --- a/spec/cases/model_spec.rb +++ b/spec/cases/model_spec.rb @@ -22,6 +22,12 @@ class Model < ActiveRecord::Base quietly { ActiveRecord::MigrationContext.new(migrations_dir, model.connection.schema_migration).up } end + it '#do_execute' do + result = model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompact') + expect(result['data']).to eq([[1]]) + expect(result['meta']).to eq([{'name' => 't', 'type' => 'UInt8'}]) + end + describe '#create' do it 'creates a new record' do From ce5789255822979a317ceb0e174fb3c8b270be2e Mon Sep 17 00:00:00 2001 From: Paulo Miranda <32435715+PauloMiranda98@users.noreply.github.com> Date: Wed, 14 Feb 2024 03:15:33 -0300 Subject: [PATCH 05/21] Add support for binary string (#116) * Add support for binary string --- .../clickhouse/schema_statements.rb | 48 ++++++++++++++++--- spec/cases/model_spec.rb | 38 +++++++++++++-- .../add_sample_data/1_create_sample_table.rb | 1 + 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index 86229510..5ef0add2 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -6,6 +6,8 @@ module ActiveRecord module ConnectionAdapters module Clickhouse module SchemaStatements + DEFAULT_RESPONSE_FORMAT = 'JSONCompactEachRowWithNamesAndTypes'.freeze + def execute(sql, name = nil, settings: {}) do_execute(sql, name, settings: settings) end @@ -64,19 +66,19 @@ def data_sources def do_system_execute(sql, name = nil) log_with_debug(sql, "#{adapter_name} #{name}") do - res = @connection.post("/?#{@connection_config.to_param}", "#{sql} FORMAT JSONCompact", 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") + res = @connection.post("/?#{@connection_config.to_param}", "#{sql} FORMAT #{DEFAULT_RESPONSE_FORMAT}", 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") - process_response(res) + process_response(res, DEFAULT_RESPONSE_FORMAT) end end - def do_execute(sql, name = nil, format: 'JSONCompact', settings: {}) + def do_execute(sql, name = nil, format: DEFAULT_RESPONSE_FORMAT, settings: {}) log(sql, "#{adapter_name} #{name}") do formatted_sql = apply_format(sql, format) request_params = @connection_config || {} res = @connection.post("/?#{request_params.merge(settings).to_param}", formatted_sql, 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") - process_response(res) + process_response(res, format) end end @@ -116,13 +118,13 @@ def apply_format(sql, format) format ? "#{sql} FORMAT #{format}" : sql end - def process_response(res) + def process_response(res, format) case res.code.to_i when 200 if res.body.to_s.include?("DB::Exception") raise ActiveRecord::ActiveRecordError, "Response code: #{res.code}:\n#{res.body}" else - res.body.presence && JSON.parse(res.body) + format_body_response(res.body, format) end else case res.body @@ -203,6 +205,40 @@ def extract_default_function(default_value, default) # :nodoc: def has_default_function?(default_value, default) # :nodoc: !default_value && (%r{\w+\(.*\)} === default) end + + def format_body_response(body, format) + return body if body.blank? + + case format + when 'JSONCompact' + format_from_json_compact(body) + when 'JSONCompactEachRowWithNamesAndTypes' + format_from_json_compact_each_row_with_names_and_types(body) + else + body + end + end + + def format_from_json_compact(body) + JSON.parse(body) + end + + def format_from_json_compact_each_row_with_names_and_types(body) + rows = body.split("\n").map { |row| JSON.parse(row) } + names, types, *data = rows + + meta = names.zip(types).map do |name, type| + { + 'name' => name, + 'type' => type + } + end + + { + 'meta' => meta, + 'data' => data + } + end end end end diff --git a/spec/cases/model_spec.rb b/spec/cases/model_spec.rb index 38c0420d..165afbae 100644 --- a/spec/cases/model_spec.rb +++ b/spec/cases/model_spec.rb @@ -22,12 +22,29 @@ class Model < ActiveRecord::Base quietly { ActiveRecord::MigrationContext.new(migrations_dir, model.connection.schema_migration).up } end - it '#do_execute' do - result = model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompact') - expect(result['data']).to eq([[1]]) - expect(result['meta']).to eq([{'name' => 't', 'type' => 'UInt8'}]) - end + describe '#do_execute' do + it 'returns formatted result' do + result = model.connection.do_execute('SELECT 1 AS t') + expect(result['data']).to eq([[1]]) + expect(result['meta']).to eq([{ 'name' => 't', 'type' => 'UInt8' }]) + end + context 'with JSONCompact format' do + it 'returns formatted result' do + result = model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompact') + expect(result['data']).to eq([[1]]) + expect(result['meta']).to eq([{ 'name' => 't', 'type' => 'UInt8' }]) + end + end + + context 'with JSONCompactEachRowWithNamesAndTypes format' do + it 'returns formatted result' do + result = model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompactEachRowWithNamesAndTypes') + expect(result['data']).to eq([[1]]) + expect(result['meta']).to eq([{ 'name' => 't', 'type' => 'UInt8' }]) + end + end + end describe '#create' do it 'creates a new record' do @@ -104,6 +121,17 @@ class Model < ActiveRecord::Base end end + describe 'string column type as byte array' do + let(:bytes) { (0..255).to_a } + let!(:record1) { model.create!(event_name: 'some event', byte_array: bytes.pack('C*')) } + + it 'keeps all bytes' do + returned_byte_array = model.first.byte_array + + expect(returned_byte_array.unpack('C*')).to eq(bytes) + end + end + describe 'UUID column type' do let(:random_uuid) { SecureRandom.uuid } let!(:record1) do diff --git a/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb b/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb index 8a2570ac..699c4da7 100644 --- a/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb +++ b/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb @@ -9,6 +9,7 @@ def up t.date :date, null: false t.datetime :datetime, null: false t.datetime :datetime64, precision: 3, null: true + t.string :byte_array, null: true t.uuid :relation_uuid end end From 4087d02e0a8dec4822f99e40f5f434ddc1358dfd Mon Sep 17 00:00:00 2001 From: jenskdsgn Date: Tue, 5 Mar 2024 11:35:27 +0100 Subject: [PATCH 06/21] Fix schema_migrations insert failing (#122) --- .../connection_adapters/clickhouse/schema_statements.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index 5ef0add2..bb93ff45 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -98,7 +98,7 @@ def assume_migrated_upto_version(version, migrations_paths = nil) if (duplicate = inserting.detect { |v| inserting.count(v) > 1 }) raise "Duplicate migration #{duplicate}. Please renumber your migrations to resolve the conflict." end - do_execute(insert_versions_sql(inserting), nil, settings: {max_partitions_per_insert_block: [100, inserting.size].max}) + do_execute(insert_versions_sql(inserting), nil, format: nil, settings: {max_partitions_per_insert_block: [100, inserting.size].max}) end end From 8337d4f19ba35478c999c44f8784497bea9cd7c0 Mon Sep 17 00:00:00 2001 From: jenskdsgn Date: Mon, 11 Mar 2024 19:08:08 +0100 Subject: [PATCH 07/21] Fix wrong method arguments (#123) --- lib/active_record/connection_adapters/clickhouse_adapter.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index d90ded3f..8b8ce4a6 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -267,7 +267,7 @@ def create_database(name) sql = apply_cluster "CREATE DATABASE #{quote_table_name(name)}" log_with_debug(sql, adapter_name) do res = @connection.post("/?#{@connection_config.except(:database).to_param}", sql) - process_response(res) + process_response(res, DEFAULT_RESPONSE_FORMAT) end end @@ -312,7 +312,7 @@ def drop_database(name) #:nodoc: sql = apply_cluster "DROP DATABASE IF EXISTS #{quote_table_name(name)}" log_with_debug(sql, adapter_name) do res = @connection.post("/?#{@connection_config.except(:database).to_param}", sql) - process_response(res) + process_response(res, DEFAULT_RESPONSE_FORMAT) end end From ca4635230df39f44f1c89b98b8318578fe887cd5 Mon Sep 17 00:00:00 2001 From: nixx Date: Mon, 11 Mar 2024 21:20:46 +0300 Subject: [PATCH 08/21] add spec for pr #123 --- spec/cases/migration_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/cases/migration_spec.rb b/spec/cases/migration_spec.rb index 6fb590a6..9f2de6cc 100644 --- a/spec/cases/migration_spec.rb +++ b/spec/cases/migration_spec.rb @@ -21,6 +21,16 @@ quietly { migration_context.up } end + context 'database creation' do + let(:db) { (0...8).map { (65 + rand(26)).chr }.join.downcase } + + it 'create' do + model.connection.create_database(db) + end + + after { model.connection.drop_database(db) } + end + context 'table creation' do context 'plain' do let(:directory) { 'plain_table_creation' } From daabfa269da1efae82e8840afc80a6c9035a7974 Mon Sep 17 00:00:00 2001 From: nixx Date: Thu, 14 Mar 2024 10:05:09 +0300 Subject: [PATCH 09/21] add github workflows, fix spec delete and update, fix injection internal and schema classes for rails 7 --- .docker/clickhouse/cluster/server1_config.xml | 117 ++++++++++++++ .docker/clickhouse/cluster/server2_config.xml | 117 ++++++++++++++ .docker/clickhouse/single/config.xml | 54 +++++++ .docker/clickhouse/users.xml | 34 +++++ .docker/docker-compose.cluster.yml | 41 +++++ .docker/docker-compose.yml | 14 ++ .docker/nginx/local.conf | 12 ++ .github/workflows/testing.yml | 75 +++++++++ CHANGELOG.md | 28 ++++ .../clickhouse/schema_statements.rb | 35 +++-- .../connection_adapters/clickhouse_adapter.rb | 9 +- lib/arel/visitors/clickhouse.rb | 7 + lib/clickhouse-activerecord.rb | 4 +- lib/clickhouse-activerecord/version.rb | 2 +- .../active_record/internal_metadata.rb | 34 +++-- .../active_record/schema_migration.rb | 2 +- spec/cluster/migration_spec.rb | 108 +++++++++++++ .../add_sample_data/2_create_join_table.rb | 1 + .../1_create_some_table.rb | 2 +- spec/{cases => single}/migration_spec.rb | 97 +----------- spec/{cases => single}/model_spec.rb | 143 +++++++++++------- spec/spec_helper.rb | 17 +-- 22 files changed, 757 insertions(+), 196 deletions(-) create mode 100644 .docker/clickhouse/cluster/server1_config.xml create mode 100644 .docker/clickhouse/cluster/server2_config.xml create mode 100644 .docker/clickhouse/single/config.xml create mode 100644 .docker/clickhouse/users.xml create mode 100644 .docker/docker-compose.cluster.yml create mode 100644 .docker/docker-compose.yml create mode 100644 .docker/nginx/local.conf create mode 100644 .github/workflows/testing.yml create mode 100644 spec/cluster/migration_spec.rb rename spec/{cases => single}/migration_spec.rb (74%) rename spec/{cases => single}/model_spec.rb (64%) diff --git a/.docker/clickhouse/cluster/server1_config.xml b/.docker/clickhouse/cluster/server1_config.xml new file mode 100644 index 00000000..ecebb8c3 --- /dev/null +++ b/.docker/clickhouse/cluster/server1_config.xml @@ -0,0 +1,117 @@ + + + + 8123 + 9009 + clickhouse1 + + users.xml + default + default + + 5368709120 + + /var/lib/clickhouse/ + /var/lib/clickhouse/tmp/ + /var/lib/clickhouse/user_files/ + /var/lib/clickhouse/access/ + 3 + + + debug + /var/log/clickhouse-server/clickhouse-server.log + /var/log/clickhouse-server/clickhouse-server.err.log + 1000M + 10 + 1 + + + + + + + clickhouse1 + 9000 + + + clickhouse2 + 9000 + + + + + + + 9181 + 1 + /var/lib/clickhouse/coordination/log + /var/lib/clickhouse/coordination/snapshots + + + 10000 + 30000 + trace + 10000 + + + + + 1 + clickhouse1 + 9000 + + + 2 + clickhouse2 + 9000 + + + + + + + clickhouse1 + 9181 + + + clickhouse2 + 9181 + + + + + test_cluster + clickhouse1 + 1 + + + + /clickhouse/test_cluster/task_queue/ddl + + + + system + query_log
+ toYYYYMM(event_date) + 1000 +
+ + +
+ Access-Control-Allow-Origin + * +
+
+ Access-Control-Allow-Headers + accept, origin, x-requested-with, content-type, authorization +
+
+ Access-Control-Allow-Methods + POST, GET, OPTIONS +
+
+ Access-Control-Max-Age + 86400 +
+
+
diff --git a/.docker/clickhouse/cluster/server2_config.xml b/.docker/clickhouse/cluster/server2_config.xml new file mode 100644 index 00000000..83d7bbb1 --- /dev/null +++ b/.docker/clickhouse/cluster/server2_config.xml @@ -0,0 +1,117 @@ + + + + 8123 + 9009 + clickhouse2 + + users.xml + default + default + + 5368709120 + + /var/lib/clickhouse/ + /var/lib/clickhouse/tmp/ + /var/lib/clickhouse/user_files/ + /var/lib/clickhouse/access/ + 3 + + + debug + /var/log/clickhouse-server/clickhouse-server.log + /var/log/clickhouse-server/clickhouse-server.err.log + 1000M + 10 + 1 + + + + + + + clickhouse1 + 9000 + + + clickhouse2 + 9000 + + + + + + + 9181 + 2 + /var/lib/clickhouse/coordination/log + /var/lib/clickhouse/coordination/snapshots + + + 10000 + 30000 + trace + 10000 + + + + + 1 + clickhouse1 + 9000 + + + 2 + clickhouse2 + 9000 + + + + + + + clickhouse1 + 9181 + + + clickhouse2 + 9181 + + + + + test_cluster + clickhouse2 + 1 + + + + /clickhouse/test_cluster/task_queue/ddl + + + + system + query_log
+ toYYYYMM(event_date) + 1000 +
+ + +
+ Access-Control-Allow-Origin + * +
+
+ Access-Control-Allow-Headers + accept, origin, x-requested-with, content-type, authorization +
+
+ Access-Control-Allow-Methods + POST, GET, OPTIONS +
+
+ Access-Control-Max-Age + 86400 +
+
+
diff --git a/.docker/clickhouse/single/config.xml b/.docker/clickhouse/single/config.xml new file mode 100644 index 00000000..218229cd --- /dev/null +++ b/.docker/clickhouse/single/config.xml @@ -0,0 +1,54 @@ + + + + 8123 + 9000 + + users.xml + default + default + + 5368709120 + + /var/lib/clickhouse/ + /var/lib/clickhouse/tmp/ + /var/lib/clickhouse/user_files/ + /var/lib/clickhouse/access/ + 3 + + + debug + /var/log/clickhouse-server/clickhouse-server.log + /var/log/clickhouse-server/clickhouse-server.err.log + 1000M + 10 + 1 + + + + system + query_log
+ toYYYYMM(event_date) + 1000 +
+ + +
+ Access-Control-Allow-Origin + * +
+
+ Access-Control-Allow-Headers + accept, origin, x-requested-with, content-type, authorization +
+
+ Access-Control-Allow-Methods + POST, GET, OPTIONS +
+
+ Access-Control-Max-Age + 86400 +
+
+ +
diff --git a/.docker/clickhouse/users.xml b/.docker/clickhouse/users.xml new file mode 100644 index 00000000..61188536 --- /dev/null +++ b/.docker/clickhouse/users.xml @@ -0,0 +1,34 @@ + + + + + + random + + + + + + + + ::/0 + + default + default + 1 + + + + + + + 3600 + 0 + 0 + 0 + 0 + 0 + + + + diff --git a/.docker/docker-compose.cluster.yml b/.docker/docker-compose.cluster.yml new file mode 100644 index 00000000..dd59ccd4 --- /dev/null +++ b/.docker/docker-compose.cluster.yml @@ -0,0 +1,41 @@ +version: '3.5' + +services: + clickhouse1: + image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-23.11-alpine}' + ulimits: + nofile: + soft: 262144 + hard: 262144 + hostname: clickhouse1 + container_name: clickhouse-activerecord-clickhouse-server-1 + ports: + - '8124:8123' + - '9001:9000' + volumes: + - './clickhouse/cluster/server1_config.xml:/etc/clickhouse-server/config.xml' + - './clickhouse/users.xml:/etc/clickhouse-server/users.xml' + + clickhouse2: + image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-23.11-alpine}' + ulimits: + nofile: + soft: 262144 + hard: 262144 + hostname: clickhouse2 + container_name: clickhouse-activerecord-clickhouse-server-2 + ports: + - '8125:8123' + volumes: + - './clickhouse/cluster/server2_config.xml:/etc/clickhouse-server/config.xml' + - './clickhouse/users.xml:/etc/clickhouse-server/users.xml' + + # Using Nginx as a cluster entrypoint and a round-robin load balancer for HTTP requests + nginx: + image: 'nginx:1.23.1-alpine' + hostname: nginx + ports: + - '28123:8123' + volumes: + - './nginx/local.conf:/etc/nginx/conf.d/local.conf' + container_name: clickhouse-activerecord-nginx diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml new file mode 100644 index 00000000..1176e95c --- /dev/null +++ b/.docker/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' +services: + clickhouse: + image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-23.11-alpine}' + container_name: 'clickhouse-activerecord-clickhouse-server' + ports: + - '18123:8123' + ulimits: + nofile: + soft: 262144 + hard: 262144 + volumes: + - './clickhouse/single/config.xml:/etc/clickhouse-server/config.xml' + - './clickhouse/users.xml:/etc/clickhouse-server/users.xml' diff --git a/.docker/nginx/local.conf b/.docker/nginx/local.conf new file mode 100644 index 00000000..35fd4512 --- /dev/null +++ b/.docker/nginx/local.conf @@ -0,0 +1,12 @@ +upstream clickhouse_cluster { + server clickhouse1:8123; + server clickhouse2:8123; +} + +server { + listen 8123; + client_max_body_size 100M; + location / { + proxy_pass http://clickhouse_cluster; + } +} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 00000000..d42e8e02 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,75 @@ +name: Testing + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + tests_single: + name: Testing single server + runs-on: ubuntu-latest + + env: + CLICKHOUSE_PORT: 18123 + CLICKHOUSE_DATABASE: default + + strategy: + fail-fast: true + matrix: + ruby-version: [ '3.0' ] + clickhouse: [ '22.1' ] + + steps: + - uses: actions/checkout@v4 + + - name: Start ClickHouse (version - ${{ matrix.clickhouse }}) in Docker + uses: isbang/compose-action@v1.5.1 + env: + CLICKHOUSE_VERSION: ${{ matrix.clickhouse }} + with: + compose-file: '.docker/docker-compose.yml' + down-flags: '--volumes' + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - run: bundle exec rspec spec/single + + tests_cluster: + name: Testing cluster server + runs-on: ubuntu-latest + + env: + CLICKHOUSE_PORT: 28123 + CLICKHOUSE_DATABASE: default + CLICKHOUSE_CLUSTER: test_cluster + + strategy: + fail-fast: true + matrix: + ruby-version: [ '3.0' ] + clickhouse: [ '22.1' ] + + steps: + - uses: actions/checkout@v4 + + - name: Start ClickHouse Cluster (version - ${{ matrix.clickhouse }}) in Docker + uses: isbang/compose-action@v1.5.1 + env: + CLICKHOUSE_VERSION: ${{ matrix.clickhouse }} + with: + compose-file: '.docker/docker-compose.cluster.yml' + down-flags: '--volumes' + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - run: bundle exec rspec spec/cluster diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dbfb857..02636c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +### Version 1.0.5 (Mar 14, 2024) + +* GitHub workflows +* Fix injection internal and schema classes for rails 7 +* Add support for binary string by [@PauloMiranda98](https://github.com/PauloMiranda98) in (#116) + +### Version 1.0.4 (Feb 2, 2024) + +* Use ILIKE for `model.arel_table[:column]#matches` by [@stympy](https://github.com/stympy) in (#115) +* Fixed `insert_all` for array column (#71) +* Register Bool and UUID in type map by [@lukinski](https://github.com/lukinski) in (#110) +* Refactoring `final` method +* Support update & delete for clickhouse from version 23.3 and newer (#93) + +### Version 1.0.0 (Nov 29, 2023) + + * Full support Rails 7.1+ + * Full support primary or multiple databases + +### Version 0.6.0 (Oct 19, 2023) + + * Added `Bool` column type instead `Uint8` (#78). Supports ClickHouse 22+ database only + * Added `final` method (#81) (The `ar_internal_metadata` table needs to be deleted after a gem update) + * Added `settings` method (#82) + * Fixed convert aggregation type (#92) + * Fixed raise error not database exist (#91) + * Fixed internal metadata update (#84) + ### Version 0.5.10 (Jun 22, 2022) * Fixes to create_table method (#70) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index bb93ff45..d7a3386d 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -35,13 +35,20 @@ def exec_insert_all(sql, name) # @link https://clickhouse.com/docs/en/sql-reference/statements/alter/update def exec_update(_sql, _name = nil, _binds = []) do_execute(_sql, _name, format: nil) - true + 0 end # @link https://clickhouse.com/docs/en/sql-reference/statements/delete def exec_delete(_sql, _name = nil, _binds = []) - do_execute(_sql, _name, format: nil) - true + log(_sql, "#{adapter_name} #{_name}") do + res = request(_sql) + begin + data = JSON.parse(res.header['x-clickhouse-summary']) + data['result_rows'].to_i + rescue JSONError + 0 + end + end end def tables(name = nil) @@ -66,18 +73,14 @@ def data_sources def do_system_execute(sql, name = nil) log_with_debug(sql, "#{adapter_name} #{name}") do - res = @connection.post("/?#{@connection_config.to_param}", "#{sql} FORMAT #{DEFAULT_RESPONSE_FORMAT}", 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") - + res = request(sql, DEFAULT_RESPONSE_FORMAT) process_response(res, DEFAULT_RESPONSE_FORMAT) end end def do_execute(sql, name = nil, format: DEFAULT_RESPONSE_FORMAT, settings: {}) log(sql, "#{adapter_name} #{name}") do - formatted_sql = apply_format(sql, format) - request_params = @connection_config || {} - res = @connection.post("/?#{request_params.merge(settings).to_param}", formatted_sql, 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") - + res = request(sql, format, settings) process_response(res, format) end end @@ -114,6 +117,17 @@ def with_yaml_fallback(value) # :nodoc: private + # Make HTTP request to ClickHouse server + # @param [String] sql + # @param [String, nil] format + # @param [Hash] settings + # @return [Net::HTTPResponse] + def request(sql, format = nil, settings = {}) + formatted_sql = apply_format(sql, format) + request_params = @connection_config || {} + @connection.post("/?#{request_params.merge(settings).to_param}", formatted_sql, 'User-Agent' => "Clickhouse ActiveRecord #{ClickhouseActiverecord::VERSION}") + end + def apply_format(sql, format) format ? "#{sql} FORMAT #{format}" : sql end @@ -170,8 +184,7 @@ def table_structure(table_name) return data unless data.empty? - raise ActiveRecord::StatementInvalid, - "Could not find table '#{table_name}'" + raise ActiveRecord::StatementInvalid, "Could not find table '#{table_name}'" end alias column_definitions table_structure diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index 8b8ce4a6..76f85d80 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -73,10 +73,11 @@ def is_view def is_view=(value) @is_view = value end - # - # def arel_table # :nodoc: - # @arel_table ||= Arel::Table.new(table_name, type_caster: type_caster) - # end + + def _delete_record(constraints) + raise ActiveRecord::ActiveRecordError.new('Deleting a row is not possible without a primary key') unless self.primary_key + super + end end end diff --git a/lib/arel/visitors/clickhouse.rb b/lib/arel/visitors/clickhouse.rb index f39942d4..a8fd5993 100644 --- a/lib/arel/visitors/clickhouse.rb +++ b/lib/arel/visitors/clickhouse.rb @@ -13,6 +13,13 @@ def aggregate(name, o, collector) end end + # https://clickhouse.com/docs/en/sql-reference/statements/delete + # DELETE and UPDATE in ClickHouse working only without table name + def visit_Arel_Attributes_Attribute(o, collector) + collector << quote_table_name(o.relation.table_alias || o.relation.name) << '.' unless collector.value.start_with?('DELETE FROM ') || collector.value.include?(' UPDATE ') + collector << quote_column_name(o.name) + end + def visit_Arel_Nodes_SelectOptions(o, collector) maybe_visit o.settings, super end diff --git a/lib/clickhouse-activerecord.rb b/lib/clickhouse-activerecord.rb index 67f95bda..4eeb4158 100644 --- a/lib/clickhouse-activerecord.rb +++ b/lib/clickhouse-activerecord.rb @@ -24,9 +24,9 @@ module ClickhouseActiverecord def self.load - ActiveRecord::InternalMetadata.singleton_class.prepend(CoreExtensions::ActiveRecord::InternalMetadata::ClassMethods) + ActiveRecord::InternalMetadata.prepend(CoreExtensions::ActiveRecord::InternalMetadata::ClassMethods) ActiveRecord::Relation.prepend(CoreExtensions::ActiveRecord::Relation) - ActiveRecord::SchemaMigration.singleton_class.prepend(CoreExtensions::ActiveRecord::SchemaMigration::ClassMethods) + ActiveRecord::SchemaMigration.prepend(CoreExtensions::ActiveRecord::SchemaMigration::ClassMethods) Arel::Nodes::SelectCore.prepend(CoreExtensions::Arel::Nodes::SelectCore) Arel::Nodes::SelectStatement.prepend(CoreExtensions::Arel::Nodes::SelectStatement) diff --git a/lib/clickhouse-activerecord/version.rb b/lib/clickhouse-activerecord/version.rb index 3d160dec..a03d7693 100644 --- a/lib/clickhouse-activerecord/version.rb +++ b/lib/clickhouse-activerecord/version.rb @@ -1,3 +1,3 @@ module ClickhouseActiverecord - VERSION = '1.0.4' + VERSION = '1.0.5' end diff --git a/lib/core_extensions/active_record/internal_metadata.rb b/lib/core_extensions/active_record/internal_metadata.rb index 79f41b79..85b28e24 100644 --- a/lib/core_extensions/active_record/internal_metadata.rb +++ b/lib/core_extensions/active_record/internal_metadata.rb @@ -3,17 +3,6 @@ module ActiveRecord module InternalMetadata module ClassMethods - def []=(key, value) - row = final.find_by(key: key) - if row.nil? || row.value != value - create!(key: key, value: value) - end - end - - def [](key) - final.where(key: key).pluck(:value).first - end - def create_table return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) return if table_exists? || !enabled? @@ -21,7 +10,7 @@ def create_table key_options = connection.internal_string_options_for_primary_key table_options = { id: false, - options: connection.adapter_name.downcase == 'clickhouse' ? 'ReplacingMergeTree(created_at) PARTITION BY key ORDER BY key' : '', + options: 'ReplacingMergeTree(created_at) PARTITION BY key ORDER BY key', if_not_exists: true } full_config = connection.instance_variable_get(:@config) || {} @@ -40,6 +29,27 @@ def create_table t.timestamps end end + + private + + def update_entry(key, new_value) + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + + create_entry(key, new_value) + end + + def select_entry(key) + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + + sm = ::Arel::SelectManager.new(arel_table) + sm.final! if connection.table_options(table_name)[:options] =~ /^ReplacingMergeTree/ + sm.project(::Arel.star) + sm.where(arel_table[primary_key].eq(::Arel::Nodes::BindParam.new(key))) + sm.order(arel_table[primary_key].asc) + sm.limit = 1 + + connection.select_one(sm, "#{self.class} Load") + end end end end diff --git a/lib/core_extensions/active_record/schema_migration.rb b/lib/core_extensions/active_record/schema_migration.rb index a6de4ffb..6f36ad97 100644 --- a/lib/core_extensions/active_record/schema_migration.rb +++ b/lib/core_extensions/active_record/schema_migration.rb @@ -32,7 +32,7 @@ def create_table def delete_version(version) return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) - im = Arel::InsertManager.new(arel_table) + im = ::Arel::InsertManager.new(arel_table) im.insert(arel_table[primary_key] => version.to_s, arel_table['active'] => 0) connection.insert(im, "#{self.class} Create Rollback Version", primary_key, version) end diff --git a/spec/cluster/migration_spec.rb b/spec/cluster/migration_spec.rb new file mode 100644 index 00000000..59782e22 --- /dev/null +++ b/spec/cluster/migration_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +RSpec.describe 'Cluster Migration', :migrations do + describe 'performs migrations' do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = 'some' + end + end + let(:directory) { raise 'NotImplemented' } + let(:migrations_dir) { File.join(FIXTURES_PATH, 'migrations', directory) } + let(:migration_context) { ActiveRecord::MigrationContext.new(migrations_dir, model.connection.schema_migration, model.connection.internal_metadata) } + + connection_config = ActiveRecord::Base.connection_db_config.configuration_hash + + before(:all) do + raise 'Unknown cluster name in config' if connection_config[:cluster_name].blank? + end + + subject do + quietly { migration_context.up } + end + + context 'dsl' do + context 'with distributed' do + let(:model_distributed) do + Class.new(ActiveRecord::Base) do + self.table_name = 'some_distributed' + end + end + + let(:directory) { 'dsl_create_table_with_distributed' } + it 'creates a table with distributed table' do + subject + + current_schema = schema(model) + current_schema_distributed = schema(model_distributed) + + expect(current_schema.keys.count).to eq(1) + expect(current_schema_distributed.keys.count).to eq(1) + + expect(current_schema).to have_key('date') + expect(current_schema_distributed).to have_key('date') + + expect(current_schema['date'].sql_type).to eq('Date') + expect(current_schema_distributed['date'].sql_type).to eq('Date') + end + + it 'drops a table with distributed table' do + subject + + expect(ActiveRecord::Base.connection.tables).to include('some') + expect(ActiveRecord::Base.connection.tables).to include('some_distributed') + + quietly do + migration_context.down + end + + expect(ActiveRecord::Base.connection.tables).not_to include('some') + expect(ActiveRecord::Base.connection.tables).not_to include('some_distributed') + end + end + end + + context 'with alias in cluster_name' do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = 'some' + end + end + connection_config = ActiveRecord::Base.connection_db_config.configuration_hash + + before(:all) do + ActiveRecord::Base.establish_connection(connection_config.merge(cluster_name: '{cluster}')) + end + + after(:all) do + ActiveRecord::Base.establish_connection(connection_config) + end + + let(:directory) { 'dsl_create_table_with_cluster_name_alias' } + it 'creates a table' do + subject + + current_schema = schema(model) + + expect(current_schema.keys.count).to eq(1) + expect(current_schema).to have_key('date') + expect(current_schema['date'].sql_type).to eq('Date') + end + + it 'drops a table' do + subject + + expect(ActiveRecord::Base.connection.tables).to include('some') + + # Need for sync between clickhouse servers + ActiveRecord::Base.connection.execute('SELECT * FROM schema_migrations') + + quietly do + migration_context.down + end + + expect(ActiveRecord::Base.connection.tables).not_to include('some') + end + end + end +end diff --git a/spec/fixtures/migrations/add_sample_data/2_create_join_table.rb b/spec/fixtures/migrations/add_sample_data/2_create_join_table.rb index bfbacbcd..7f4fb543 100644 --- a/spec/fixtures/migrations/add_sample_data/2_create_join_table.rb +++ b/spec/fixtures/migrations/add_sample_data/2_create_join_table.rb @@ -4,6 +4,7 @@ class CreateJoinTable < ActiveRecord::Migration[5.0] def up create_table :joins, options: 'MergeTree PARTITION BY toYYYYMM(date) ORDER BY (event_name)' do |t| t.string :event_name, null: false + t.integer :event_value t.integer :join_value t.date :date, null: false end diff --git a/spec/fixtures/migrations/dsl_create_table_with_cluster_name_alias/1_create_some_table.rb b/spec/fixtures/migrations/dsl_create_table_with_cluster_name_alias/1_create_some_table.rb index 45f38644..0fc454df 100644 --- a/spec/fixtures/migrations/dsl_create_table_with_cluster_name_alias/1_create_some_table.rb +++ b/spec/fixtures/migrations/dsl_create_table_with_cluster_name_alias/1_create_some_table.rb @@ -1,6 +1,6 @@ class CreateSomeTable < ActiveRecord::Migration[5.0] def change - create_table :some, options: 'MergeTree PARTITION BY toYYYYMM(date) ORDER BY (date)' do |t| + create_table :some, options: 'MergeTree PARTITION BY toYYYYMM(date) ORDER BY (date)', sync: true, id: false do |t| t.date :date, null: false end end diff --git a/spec/cases/migration_spec.rb b/spec/single/migration_spec.rb similarity index 74% rename from spec/cases/migration_spec.rb rename to spec/single/migration_spec.rb index 9f2de6cc..163452b8 100644 --- a/spec/cases/migration_spec.rb +++ b/spec/single/migration_spec.rb @@ -11,11 +11,7 @@ let(:migrations_dir) { File.join(FIXTURES_PATH, 'migrations', directory) } let(:migration_context) { ActiveRecord::MigrationContext.new(migrations_dir, model.connection.schema_migration, model.connection.internal_metadata) } - if ActiveRecord::version >= Gem::Version.new('6.1') - connection_config = ActiveRecord::Base.connection_db_config.configuration_hash - else - connection_config = ActiveRecord::Base.connection_config - end + connection_config = ActiveRecord::Base.connection_db_config.configuration_hash subject do quietly { migration_context.up } @@ -190,53 +186,6 @@ end end - context 'with distributed' do - let(:model_distributed) do - Class.new(ActiveRecord::Base) do - self.table_name = 'some_distributed' - end - end - - before(:all) do - ActiveRecord::Base.establish_connection(connection_config.merge(cluster_name: CLUSTER_NAME)) - end - - after(:all) do - ActiveRecord::Base.establish_connection(connection_config) - end - - let(:directory) { 'dsl_create_table_with_distributed' } - it 'creates a table with distributed table' do - subject - - current_schema = schema(model) - current_schema_distributed = schema(model_distributed) - - expect(current_schema.keys.count).to eq(1) - expect(current_schema_distributed.keys.count).to eq(1) - - expect(current_schema).to have_key('date') - expect(current_schema_distributed).to have_key('date') - - expect(current_schema['date'].sql_type).to eq('Date') - expect(current_schema_distributed['date'].sql_type).to eq('Date') - end - - it 'drops a table with distributed table' do - subject - - expect(ActiveRecord::Base.connection.tables).to include('some') - expect(ActiveRecord::Base.connection.tables).to include('some_distributed') - - quietly do - migration_context.down - end - - expect(ActiveRecord::Base.connection.tables).not_to include('some') - expect(ActiveRecord::Base.connection.tables).not_to include('some_distributed') - end - end - context 'creates a view' do let(:directory) { 'dsl_create_view_with_to_section' } it 'creates a view' do @@ -261,50 +210,6 @@ end end end - - context 'with alias in cluster_name' do - let(:model) do - Class.new(ActiveRecord::Base) do - self.table_name = 'some' - end - end - if ActiveRecord::version >= Gem::Version.new('6.1') - connection_config = ActiveRecord::Base.connection_db_config.configuration_hash - else - connection_config = ActiveRecord::Base.connection_config - end - - before(:all) do - ActiveRecord::Base.establish_connection(connection_config.merge(cluster_name: '{cluster}')) - end - - after(:all) do - ActiveRecord::Base.establish_connection(connection_config) - end - - let(:directory) { 'dsl_create_table_with_cluster_name_alias' } - it 'creates a table' do - subject - - current_schema = schema(model) - - expect(current_schema.keys.count).to eq(1) - expect(current_schema).to have_key('date') - expect(current_schema['date'].sql_type).to eq('Date') - end - - it 'drops a table' do - subject - - expect(ActiveRecord::Base.connection.tables).to include('some') - - quietly do - migration_context.down - end - - expect(ActiveRecord::Base.connection.tables).not_to include('some') - end - end end describe 'drop table' do diff --git a/spec/cases/model_spec.rb b/spec/single/model_spec.rb similarity index 64% rename from spec/cases/model_spec.rb rename to spec/single/model_spec.rb index 165afbae..d59bc80b 100644 --- a/spec/cases/model_spec.rb +++ b/spec/single/model_spec.rb @@ -2,36 +2,38 @@ RSpec.describe 'Model', :migrations do + class ModelJoin < ActiveRecord::Base + self.table_name = 'joins' + belongs_to :model, class_name: 'Model' + end + class Model < ActiveRecord::Base + self.table_name = 'sample' + has_many :joins, class_name: 'ModelJoin', primary_key: 'event_name' + end + class ModelPk < ActiveRecord::Base + self.table_name = 'sample' + self.primary_key = 'event_name' + end + let(:date) { Date.today } context 'sample' do - let!(:model) do - class ModelJoin < ActiveRecord::Base - self.table_name = 'joins' - belongs_to :model, class_name: 'Model' - end - class Model < ActiveRecord::Base - self.table_name = 'sample' - has_many :joins, class_name: 'ModelJoin', primary_key: 'event_name' - end - Model - end before do migrations_dir = File.join(FIXTURES_PATH, 'migrations', 'add_sample_data') - quietly { ActiveRecord::MigrationContext.new(migrations_dir, model.connection.schema_migration).up } + quietly { ActiveRecord::MigrationContext.new(migrations_dir, Model.connection.schema_migration).up } end describe '#do_execute' do it 'returns formatted result' do - result = model.connection.do_execute('SELECT 1 AS t') + result = Model.connection.do_execute('SELECT 1 AS t') expect(result['data']).to eq([[1]]) expect(result['meta']).to eq([{ 'name' => 't', 'type' => 'UInt8' }]) end context 'with JSONCompact format' do it 'returns formatted result' do - result = model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompact') + result = Model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompact') expect(result['data']).to eq([[1]]) expect(result['meta']).to eq([{ 'name' => 't', 'type' => 'UInt8' }]) end @@ -39,7 +41,7 @@ class Model < ActiveRecord::Base context 'with JSONCompactEachRowWithNamesAndTypes format' do it 'returns formatted result' do - result = model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompactEachRowWithNamesAndTypes') + result = Model.connection.do_execute('SELECT 1 AS t', format: 'JSONCompactEachRowWithNamesAndTypes') expect(result['data']).to eq([[1]]) expect(result['meta']).to eq([{ 'name' => 't', 'type' => 'UInt8' }]) end @@ -49,84 +51,104 @@ class Model < ActiveRecord::Base describe '#create' do it 'creates a new record' do expect { - model.create!( + Model.create!( event_name: 'some event', date: date ) - }.to change { model.count } + }.to change { Model.count } end it 'insert all' do if ActiveRecord::version >= Gem::Version.new('6') - model.insert_all([ + Model.insert_all([ {event_name: 'some event 1', date: date}, {event_name: 'some event 2', date: date}, ]) - expect(model.count).to eq(2) + expect(Model.count).to eq(2) end end end describe '#update' do - let(:record) { model.create!(event_name: 'some event', date: date) } + let!(:record) { Model.create!(event_name: 'some event', event_value: 1, date: date) } + + it 'update' do + expect { + Model.where(event_name: 'some event').update_all(event_value: 2) + }.to_not raise_error + end - it 'raises an error' do - record.update!(event_name: 'new event name') - expect(model.where(event_name: 'new event name').count).to eq(1) + it 'update model with primary key' do + expect { + ModelPk.first.update!(event_value: 2) + }.to_not raise_error end end - describe '#destroy' do - let(:record) { model.create!(event_name: 'some event', date: date) } + describe '#delete' do + let!(:record) { Model.create!(event_name: 'some event', date: date) } + + it 'model destroy' do + expect { + record.destroy! + }.to raise_error(ActiveRecord::ActiveRecordError, 'Deleting a row is not possible without a primary key') + end + + it 'scope' do + expect { + Model.where(event_name: 'some event').delete_all + }.to_not raise_error + end - it 'raises an error' do - record.destroy! - expect(model.count).to eq(0) + it 'destroy model with primary key' do + expect { + ModelPk.first.destroy! + }.to_not raise_error end end describe '#reverse_order!' do it 'blank' do - expect(model.all.reverse_order!.map(&:event_name)).to eq([]) + expect(Model.all.reverse_order!.map(&:event_name)).to eq([]) end it 'select' do - model.create!(event_name: 'some event 1', date: 1.day.ago) - model.create!(event_name: 'some event 2', date: 2.day.ago) - expect(model.all.reverse_order!.map(&:event_name)).to eq(['some event 1', 'some event 2']) + Model.create!(event_name: 'some event 1', date: 1.day.ago) + Model.create!(event_name: 'some event 2', date: 2.day.ago) + expect(Model.all.reverse_order!.map(&:event_name)).to eq(['some event 1', 'some event 2']) end end describe 'convert type with aggregations' do - let!(:record1) { model.create!(event_name: 'some event', event_value: 1, date: date) } - let!(:record2) { model.create!(event_name: 'some event', event_value: 3, date: date) } + let!(:record1) { Model.create!(event_name: 'some event', event_value: 1, date: date) } + let!(:record2) { Model.create!(event_name: 'some event', event_value: 3, date: date) } it 'integer' do - expect(model.select(Arel.sql('sum(event_value) AS event_value')).first.event_value.class).to eq(Integer) - expect(model.select(Arel.sql('sum(event_value) AS value')).first.attributes['value'].class).to eq(Integer) - expect(model.pluck(Arel.sql('sum(event_value)')).first[0].class).to eq(Integer) + expect(Model.select(Arel.sql('sum(event_value) AS event_value')).first.event_value.class).to eq(Integer) + expect(Model.select(Arel.sql('sum(event_value) AS value')).first.attributes['value'].class).to eq(Integer) + expect(Model.pluck(Arel.sql('sum(event_value)')).first[0].class).to eq(Integer) end end describe 'boolean column type' do - let!(:record1) { model.create!(event_name: 'some event', event_value: 1, date: date) } + let!(:record1) { Model.create!(event_name: 'some event', event_value: 1, date: date) } it 'bool result' do - expect(model.first.enabled.class).to eq(FalseClass) + expect(Model.first.enabled.class).to eq(FalseClass) end it 'is mapped to :boolean' do - type = model.columns_hash['enabled'].type + type = Model.columns_hash['enabled'].type expect(type).to eq(:boolean) end end describe 'string column type as byte array' do let(:bytes) { (0..255).to_a } - let!(:record1) { model.create!(event_name: 'some event', byte_array: bytes.pack('C*')) } + let!(:record1) { Model.create!(event_name: 'some event', byte_array: bytes.pack('C*')) } it 'keeps all bytes' do - returned_byte_array = model.first.byte_array + returned_byte_array = Model.first.byte_array expect(returned_byte_array.unpack('C*')).to eq(bytes) end @@ -135,11 +157,11 @@ class Model < ActiveRecord::Base describe 'UUID column type' do let(:random_uuid) { SecureRandom.uuid } let!(:record1) do - model.create!(event_name: 'some event', event_value: 1, date: date, relation_uuid: random_uuid) + Model.create!(event_name: 'some event', event_value: 1, date: date, relation_uuid: random_uuid) end it 'is mapped to :uuid' do - type = model.columns_hash['relation_uuid'].type + type = Model.columns_hash['relation_uuid'].type expect(type).to eq(:uuid) end @@ -155,37 +177,42 @@ class Model < ActiveRecord::Base describe '#settings' do it 'works' do - sql = model.settings(optimize_read_in_order: 1, cast_keep_nullable: 1).to_sql + sql = Model.settings(optimize_read_in_order: 1, cast_keep_nullable: 1).to_sql expect(sql).to eq('SELECT sample.* FROM sample SETTINGS optimize_read_in_order = 1, cast_keep_nullable = 1') end it 'quotes' do - sql = model.settings(foo: :bar).to_sql + sql = Model.settings(foo: :bar).to_sql expect(sql).to eq('SELECT sample.* FROM sample SETTINGS foo = \'bar\'') end it 'allows passing the symbol :default to reset a setting' do - sql = model.settings(max_insert_block_size: :default).to_sql + sql = Model.settings(max_insert_block_size: :default).to_sql expect(sql).to eq('SELECT sample.* FROM sample SETTINGS max_insert_block_size = DEFAULT') end end describe '#using' do it 'works' do - sql = model.joins(:joins).using(:event_name, :date).to_sql + sql = Model.joins(:joins).using(:event_name, :date).to_sql expect(sql).to eq('SELECT sample.* FROM sample INNER JOIN joins USING event_name,date') end + + it 'works with filters' do + sql = Model.joins(:joins).using(:event_name, :date).where(joins: { event_value: 1 }).to_sql + expect(sql).to eq("SELECT sample.* FROM sample INNER JOIN joins USING event_name,date WHERE joins.event_value = 1") + end end describe 'arel predicates' do describe '#matches' do it 'uses ilike for case insensitive matches' do - sql = model.where(model.arel_table[:event_name].matches('some event')).to_sql + sql = Model.where(Model.arel_table[:event_name].matches('some event')).to_sql expect(sql).to eq("SELECT sample.* FROM sample WHERE sample.event_name ILIKE 'some event'") end it 'uses like for case sensitive matches' do - sql = model.where(model.arel_table[:event_name].matches('some event', nil, true)).to_sql + sql = Model.where(Model.arel_table[:event_name].matches('some event', nil, true)).to_sql expect(sql).to eq("SELECT sample.* FROM sample WHERE sample.event_name LIKE 'some event'") end end @@ -194,8 +221,8 @@ class Model < ActiveRecord::Base describe 'DateTime64 create' do it 'create a new record' do time = DateTime.parse('2023-07-21 08:00:00.123') - model.create!(datetime: time, datetime64: time) - row = model.first + Model.create!(datetime: time, datetime64: time) + row = Model.first expect(row.datetime).to_not eq(row.datetime64) expect(row.datetime.strftime('%Y-%m-%d %H:%M:%S')).to eq('2023-07-21 08:00:00') expect(row.datetime64.strftime('%Y-%m-%d %H:%M:%S.%3N')).to eq('2023-07-21 08:00:00.123') @@ -203,14 +230,14 @@ class Model < ActiveRecord::Base end describe 'final request' do - let!(:record1) { model.create!(date: date, event_name: '1') } - let!(:record2) { model.create!(date: date, event_name: '1') } + let!(:record1) { Model.create!(date: date, event_name: '1') } + let!(:record2) { Model.create!(date: date, event_name: '1') } it 'select' do - expect(model.count).to eq(2) - expect(model.final.count).to eq(1) - expect(model.final!.count).to eq(1) - expect(model.final.where(date: '2023-07-21').to_sql).to eq('SELECT sample.* FROM sample FINAL WHERE sample.date = \'2023-07-21\'') + expect(Model.count).to eq(2) + expect(Model.final.count).to eq(1) + expect(Model.final!.count).to eq(1) + expect(Model.final.where(date: '2023-07-21').to_sql).to eq('SELECT sample.* FROM sample FINAL WHERE sample.date = \'2023-07-21\'') end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 78e2fca4..ccf95d31 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -9,7 +9,6 @@ ClickhouseActiverecord.load FIXTURES_PATH = File.join(File.dirname(__FILE__), 'fixtures') -CLUSTER_NAME = 'test' RSpec.configure do |config| # Enable flags like --only-failures and --next-failure @@ -38,10 +37,12 @@ default: { adapter: 'clickhouse', host: 'localhost', - port: 8123, - database: 'test', + port: ENV['CLICKHOUSE_PORT'] || 8123, + database: ENV['CLICKHOUSE_DATABASE'] || 'test', username: nil, - password: nil + password: nil, + use_metadata_table: false, + cluster_name: ENV['CLICKHOUSE_CLUSTER'], } ) @@ -55,15 +56,11 @@ def schema(model) end def clear_db - if ActiveRecord::version >= Gem::Version.new('6.1') - cluster = ActiveRecord::Base.connection_db_config.configuration_hash[:cluster_name] - else - cluster = ActiveRecord::Base.connection_config[:cluster_name] - end + cluster = ActiveRecord::Base.connection_db_config.configuration_hash[:cluster_name] pattern = if cluster normalized_cluster_name = cluster.start_with?('{') ? "'#{cluster}'" : cluster - "DROP TABLE %s ON CLUSTER #{normalized_cluster_name}" + "DROP TABLE %s ON CLUSTER #{normalized_cluster_name} SYNC" else 'DROP TABLE %s' end From 535ca30d9e59323345cf253e1d1d9f692245142c Mon Sep 17 00:00:00 2001 From: nixx Date: Thu, 14 Mar 2024 18:21:03 +0300 Subject: [PATCH 10/21] refactoring --- .github/workflows/testing.yml | 10 ++- lib/clickhouse-activerecord.rb | 10 +-- lib/clickhouse-activerecord/tasks.rb | 21 +++-- .../active_record/internal_metadata.rb | 82 +++++++++---------- .../migration/command_recorder.rb | 0 .../active_record/schema_migration.rb | 58 +++++++------ lib/tasks/clickhouse.rake | 25 +++--- 7 files changed, 107 insertions(+), 99 deletions(-) rename {core_extensions => lib/core_extensions}/active_record/migration/command_recorder.rb (100%) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index d42e8e02..6cd96c8f 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,14 +17,15 @@ jobs: strategy: fail-fast: true + max-parallel: 1 matrix: - ruby-version: [ '3.0' ] + ruby-version: [ '2.7', '3.0', '3.2' ] clickhouse: [ '22.1' ] steps: - uses: actions/checkout@v4 - - name: Start ClickHouse (version - ${{ matrix.clickhouse }}) in Docker + - name: Start ClickHouse ${{ matrix.clickhouse }} uses: isbang/compose-action@v1.5.1 env: CLICKHOUSE_VERSION: ${{ matrix.clickhouse }} @@ -51,14 +52,15 @@ jobs: strategy: fail-fast: true + max-parallel: 1 matrix: - ruby-version: [ '3.0' ] + ruby-version: [ '2.7', '3.0', '3.2' ] clickhouse: [ '22.1' ] steps: - uses: actions/checkout@v4 - - name: Start ClickHouse Cluster (version - ${{ matrix.clickhouse }}) in Docker + - name: Start ClickHouse Cluster ${{ matrix.clickhouse }} uses: isbang/compose-action@v1.5.1 env: CLICKHOUSE_VERSION: ${{ matrix.clickhouse }} diff --git a/lib/clickhouse-activerecord.rb b/lib/clickhouse-activerecord.rb index 4eeb4158..40ce7ef7 100644 --- a/lib/clickhouse-activerecord.rb +++ b/lib/clickhouse-activerecord.rb @@ -5,15 +5,12 @@ require 'core_extensions/active_record/internal_metadata' require 'core_extensions/active_record/relation' require 'core_extensions/active_record/schema_migration' - +require 'core_extensions/active_record/migration/command_recorder' require 'core_extensions/arel/nodes/select_core' require 'core_extensions/arel/nodes/select_statement' require 'core_extensions/arel/select_manager' require 'core_extensions/arel/table' -require_relative '../core_extensions/active_record/migration/command_recorder' -ActiveRecord::Migration::CommandRecorder.include CoreExtensions::ActiveRecord::Migration::CommandRecorder - if defined?(Rails::Railtie) require 'clickhouse-activerecord/railtie' require 'clickhouse-activerecord/schema' @@ -24,9 +21,10 @@ module ClickhouseActiverecord def self.load - ActiveRecord::InternalMetadata.prepend(CoreExtensions::ActiveRecord::InternalMetadata::ClassMethods) + ActiveRecord::InternalMetadata.prepend(CoreExtensions::ActiveRecord::InternalMetadata) + ActiveRecord::Migration::CommandRecorder.include(CoreExtensions::ActiveRecord::Migration::CommandRecorder) ActiveRecord::Relation.prepend(CoreExtensions::ActiveRecord::Relation) - ActiveRecord::SchemaMigration.prepend(CoreExtensions::ActiveRecord::SchemaMigration::ClassMethods) + ActiveRecord::SchemaMigration.prepend(CoreExtensions::ActiveRecord::SchemaMigration) Arel::Nodes::SelectCore.prepend(CoreExtensions::Arel::Nodes::SelectCore) Arel::Nodes::SelectStatement.prepend(CoreExtensions::Arel::Nodes::SelectStatement) diff --git a/lib/clickhouse-activerecord/tasks.rb b/lib/clickhouse-activerecord/tasks.rb index e3635992..b8a4842c 100644 --- a/lib/clickhouse-activerecord/tasks.rb +++ b/lib/clickhouse-activerecord/tasks.rb @@ -5,12 +5,12 @@ class Tasks delegate :connection, :establish_connection, to: ActiveRecord::Base def initialize(configuration) - @configuration = configuration.with_indifferent_access + @configuration = configuration end def create establish_master_connection - connection.create_database @configuration['database'] + connection.create_database @configuration.database rescue ActiveRecord::StatementInvalid => e if e.cause.to_s.include?('already exists') raise ActiveRecord::DatabaseAlreadyExists @@ -21,7 +21,7 @@ def create def drop establish_master_connection - connection.drop_database @configuration['database'] + connection.drop_database @configuration.database end def purge @@ -31,12 +31,21 @@ def purge end def structure_dump(*args) - tables = connection.execute("SHOW TABLES FROM #{@configuration['database']}")['data'].flatten + establish_master_connection + + # get all tables + tables = connection.execute("SHOW TABLES FROM #{@configuration.database} WHERE name NOT LIKE '.inner_id.%'")['data'].flatten.map do |table| + next if %w[schema_migrations ar_internal_metadata].include?(table) + connection.show_create_table(table).gsub("#{@configuration.database}.", '') + end.compact + + # sort view to last + tables.sort_by! {|table| table.match(/^CREATE\s+(MATERIALIZED\s+)?VIEW/) ? 1 : 0} + # put to file File.open(args.first, 'w:utf-8') do |file| tables.each do |table| - next if table.match(/\.inner/) - file.puts connection.execute("SHOW CREATE TABLE #{table}")['data'].try(:first).try(:first).gsub("#{@configuration['database']}.", '') + ";\n\n" + file.puts table + ";\n\n" end end end diff --git a/lib/core_extensions/active_record/internal_metadata.rb b/lib/core_extensions/active_record/internal_metadata.rb index 85b28e24..5f59dbfb 100644 --- a/lib/core_extensions/active_record/internal_metadata.rb +++ b/lib/core_extensions/active_record/internal_metadata.rb @@ -1,55 +1,53 @@ module CoreExtensions module ActiveRecord module InternalMetadata - module ClassMethods - - def create_table - return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) - return if table_exists? || !enabled? - - key_options = connection.internal_string_options_for_primary_key - table_options = { - id: false, - options: 'ReplacingMergeTree(created_at) PARTITION BY key ORDER BY key', - if_not_exists: true - } - full_config = connection.instance_variable_get(:@config) || {} - - if full_config[:distributed_service_tables] - table_options.merge!(with_distributed: table_name, sharding_key: 'cityHash64(created_at)') - - distributed_suffix = "_#{full_config[:distributed_service_tables_suffix] || 'distributed'}" - else - distributed_suffix = '' - end - - connection.create_table(table_name + distributed_suffix.to_s, **table_options) do |t| - t.string :key, **key_options - t.string :value - t.timestamps - end - end - private + def create_table + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + return if table_exists? || !enabled? + + key_options = connection.internal_string_options_for_primary_key + table_options = { + id: false, + options: 'ReplacingMergeTree(created_at) PARTITION BY key ORDER BY key', + if_not_exists: true + } + full_config = connection.instance_variable_get(:@config) || {} - def update_entry(key, new_value) - return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + if full_config[:distributed_service_tables] + table_options.merge!(with_distributed: table_name, sharding_key: 'cityHash64(created_at)') - create_entry(key, new_value) + distributed_suffix = "_#{full_config[:distributed_service_tables_suffix] || 'distributed'}" + else + distributed_suffix = '' end - def select_entry(key) - return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + connection.create_table(table_name + distributed_suffix.to_s, **table_options) do |t| + t.string :key, **key_options + t.string :value + t.timestamps + end + end - sm = ::Arel::SelectManager.new(arel_table) - sm.final! if connection.table_options(table_name)[:options] =~ /^ReplacingMergeTree/ - sm.project(::Arel.star) - sm.where(arel_table[primary_key].eq(::Arel::Nodes::BindParam.new(key))) - sm.order(arel_table[primary_key].asc) - sm.limit = 1 + private - connection.select_one(sm, "#{self.class} Load") - end + def update_entry(key, new_value) + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + + create_entry(key, new_value) + end + + def select_entry(key) + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + + sm = ::Arel::SelectManager.new(arel_table) + sm.final! if connection.table_options(table_name)[:options] =~ /^ReplacingMergeTree/ + sm.project(::Arel.star) + sm.where(arel_table[primary_key].eq(::Arel::Nodes::BindParam.new(key))) + sm.order(arel_table[primary_key].asc) + sm.limit = 1 + + connection.select_one(sm, "#{self.class} Load") end end end diff --git a/core_extensions/active_record/migration/command_recorder.rb b/lib/core_extensions/active_record/migration/command_recorder.rb similarity index 100% rename from core_extensions/active_record/migration/command_recorder.rb rename to lib/core_extensions/active_record/migration/command_recorder.rb diff --git a/lib/core_extensions/active_record/schema_migration.rb b/lib/core_extensions/active_record/schema_migration.rb index 6f36ad97..0da6a7ee 100644 --- a/lib/core_extensions/active_record/schema_migration.rb +++ b/lib/core_extensions/active_record/schema_migration.rb @@ -1,47 +1,45 @@ module CoreExtensions module ActiveRecord module SchemaMigration - module ClassMethods - def create_table - return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + def create_table + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) - return if table_exists? + return if table_exists? - version_options = connection.internal_string_options_for_primary_key - table_options = { - id: false, options: 'ReplacingMergeTree(ver) ORDER BY (version)', if_not_exists: true - } - full_config = connection.instance_variable_get(:@config) || {} + version_options = connection.internal_string_options_for_primary_key + table_options = { + id: false, options: 'ReplacingMergeTree(ver) ORDER BY (version)', if_not_exists: true + } + full_config = connection.instance_variable_get(:@config) || {} - if full_config[:distributed_service_tables] - table_options.merge!(with_distributed: table_name, sharding_key: 'cityHash64(version)') + if full_config[:distributed_service_tables] + table_options.merge!(with_distributed: table_name, sharding_key: 'cityHash64(version)') - distributed_suffix = "_#{full_config[:distributed_service_tables_suffix] || 'distributed'}" - else - distributed_suffix = '' - end + distributed_suffix = "_#{full_config[:distributed_service_tables_suffix] || 'distributed'}" + else + distributed_suffix = '' + end - connection.create_table(table_name + distributed_suffix.to_s, **table_options) do |t| - t.string :version, **version_options - t.column :active, 'Int8', null: false, default: '1' - t.datetime :ver, null: false, default: -> { 'now()' } - end + connection.create_table(table_name + distributed_suffix.to_s, **table_options) do |t| + t.string :version, **version_options + t.column :active, 'Int8', null: false, default: '1' + t.datetime :ver, null: false, default: -> { 'now()' } end + end - def delete_version(version) - return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + def delete_version(version) + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) - im = ::Arel::InsertManager.new(arel_table) - im.insert(arel_table[primary_key] => version.to_s, arel_table['active'] => 0) - connection.insert(im, "#{self.class} Create Rollback Version", primary_key, version) - end + im = ::Arel::InsertManager.new(arel_table) + im.insert(arel_table[primary_key] => version.to_s, arel_table['active'] => 0) + connection.insert(im, "#{self.class} Create Rollback Version", primary_key, version) + end - def all_versions - return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) + def all_versions + return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter) - final.where(active: 1).order(:version).pluck(:version) - end + final.where(active: 1).order(:version).pluck(:version) end end end diff --git a/lib/tasks/clickhouse.rake b/lib/tasks/clickhouse.rake index eb37aea5..4854021d 100644 --- a/lib/tasks/clickhouse.rake +++ b/lib/tasks/clickhouse.rake @@ -15,15 +15,18 @@ namespace :clickhouse do # TODO: deprecated desc 'Load database schema' task load: %i[prepare_internal_metadata_table] do + puts 'Warning: `rake clickhouse:schema:load` is deprecated! Use `rake db:schema:load:clickhouse` instead' simple = ENV['simple'] || ARGV.any? { |a| a.include?('--simple') } ? '_simple' : nil ActiveRecord::Base.establish_connection(:clickhouse) - ActiveRecord::SchemaMigration.drop_table + connection = ActiveRecord::Tasks::DatabaseTasks.migration_connection + connection.schema_migration.drop_table load(Rails.root.join("db/clickhouse_schema#{simple}.rb")) end # TODO: deprecated desc 'Dump database schema' task dump: :environment do |_, args| + puts 'Warning: `rake clickhouse:schema:dump` is deprecated! Use `rake db:schema:dump:clickhouse` instead' simple = ENV['simple'] || args[:simple] || ARGV.any? { |a| a.include?('--simple') } ? '_simple' : nil filename = Rails.root.join("db/clickhouse_schema#{simple}.rb") File.open(filename, 'w:utf-8') do |file| @@ -36,43 +39,38 @@ namespace :clickhouse do namespace :structure do desc 'Load database structure' task load: ['db:check_protected_environments'] do - config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'clickhouse') ClickhouseActiverecord::Tasks.new(config).structure_load(Rails.root.join('db/clickhouse_structure.sql')) end desc 'Dump database structure' task dump: ['db:check_protected_environments'] do - config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'clickhouse') ClickhouseActiverecord::Tasks.new(config).structure_dump(Rails.root.join('db/clickhouse_structure.sql')) end end desc 'Creates the database from DATABASE_URL or config/database.yml' task create: [] do - config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'clickhouse') - ActiveRecord::Tasks::DatabaseTasks.create(config) + puts 'Warning: `rake clickhouse:create` is deprecated! Use `rake db:create:clickhouse` instead' end desc 'Drops the database from DATABASE_URL or config/database.yml' task drop: ['db:check_protected_environments'] do - config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'clickhouse') - ActiveRecord::Tasks::DatabaseTasks.drop(config) + puts 'Warning: `rake clickhouse:drop` is deprecated! Use `rake db:drop:clickhouse` instead' end desc 'Empty the database from DATABASE_URL or config/database.yml' task purge: ['db:check_protected_environments'] do - config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'clickhouse') - ActiveRecord::Tasks::DatabaseTasks.purge(config) + puts 'Warning: `rake clickhouse:purge` is deprecated! Use `rake db:reset:clickhouse` instead' end # desc 'Resets your database using your migrations for the current environment' task :reset do - Rake::Task['clickhouse:purge'].execute - Rake::Task['clickhouse:migrate'].execute + puts 'Warning: `rake clickhouse:reset` is deprecated! Use `rake db:reset:clickhouse` instead' end desc 'Migrate the clickhouse database' task migrate: %i[prepare_schema_migration_table prepare_internal_metadata_table] do + puts 'Warning: `rake clickhouse:migrate` is deprecated! Use `rake db:migrate:clickhouse` instead' Rake::Task['db:migrate:clickhouse'].execute if File.exist? "#{Rails.root}/db/clickhouse_schema_simple.rb" Rake::Task['clickhouse:schema:dump'].execute(simple: true) @@ -81,9 +79,14 @@ namespace :clickhouse do desc 'Rollback the clickhouse database' task rollback: %i[prepare_schema_migration_table prepare_internal_metadata_table] do + puts 'Warning: `rake clickhouse:rollback` is deprecated! Use `rake db:rollback:clickhouse` instead' Rake::Task['db:rollback:clickhouse'].execute if File.exist? "#{Rails.root}/db/clickhouse_schema_simple.rb" Rake::Task['clickhouse:schema:dump'].execute(simple: true) end end + + def config + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'clickhouse') + end end From d98c938e56eb759e538afbf708048f012ddd43d5 Mon Sep 17 00:00:00 2001 From: Paulo Miranda <32435715+PauloMiranda98@users.noreply.github.com> Date: Mon, 25 Mar 2024 10:45:01 -0300 Subject: [PATCH 11/21] fix non-canonical uuid (#117) --- .../clickhouse/oid/uuid.rb | 22 ++++++++++++++----- spec/single/model_spec.rb | 5 +++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/lib/active_record/connection_adapters/clickhouse/oid/uuid.rb b/lib/active_record/connection_adapters/clickhouse/oid/uuid.rb index db843cd2..7e99c08c 100644 --- a/lib/active_record/connection_adapters/clickhouse/oid/uuid.rb +++ b/lib/active_record/connection_adapters/clickhouse/oid/uuid.rb @@ -6,6 +6,7 @@ module Clickhouse module OID # :nodoc: class Uuid < Type::Value # :nodoc: ACCEPTABLE_UUID = %r{\A(\{)?([a-fA-F0-9]{4}-?){8}(?(1)\}|)\z} + CANONICAL_UUID = %r{\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z} alias :serialize :deserialize @@ -13,23 +14,32 @@ def type :uuid end - def changed?(old_value, new_value, _) + def changed?(old_value, new_value, _new_value_before_type_cast) old_value.class != new_value.class || - new_value && old_value.casecmp(new_value) != 0 + new_value != old_value end def changed_in_place?(raw_old_value, new_value) raw_old_value.class != new_value.class || - new_value && raw_old_value.casecmp(new_value) != 0 + new_value != raw_old_value end private def cast_value(value) - casted = value.to_s - casted if casted.match?(ACCEPTABLE_UUID) + value = value.to_s + format_uuid(value) if value.match?(ACCEPTABLE_UUID) end - end + + def format_uuid(uuid) + if uuid.match?(CANONICAL_UUID) + uuid + else + uuid = uuid.delete("{}-").downcase + "#{uuid[..7]}-#{uuid[8..11]}-#{uuid[12..15]}-#{uuid[16..19]}-#{uuid[20..]}" + end + end + end end end end diff --git a/spec/single/model_spec.rb b/spec/single/model_spec.rb index d59bc80b..88c9d54a 100644 --- a/spec/single/model_spec.rb +++ b/spec/single/model_spec.rb @@ -169,6 +169,11 @@ class ModelPk < ActiveRecord::Base expect(record1.relation_uuid).to eq(random_uuid) end + it 'accepts non-canonical uuid' do + record1.relation_uuid = 'ABCD-0123-4567-89EF-dead-beef-0101-1010' + expect(record1.relation_uuid).to eq('abcd0123-4567-89ef-dead-beef01011010') + end + it 'does not accept invalid values' do record1.relation_uuid = 'invalid-uuid' expect(record1.relation_uuid).to be_nil From 0e43bd7b56f043d08ed3154ce31bb7f07effaebe Mon Sep 17 00:00:00 2001 From: jenskdsgn Date: Wed, 3 Apr 2024 08:08:44 +0200 Subject: [PATCH 12/21] Fix precision loss due to JSON float parsing (#129) --- .../clickhouse/schema_statements.rb | 8 ++++++-- .../add_sample_data/1_create_sample_table.rb | 1 + spec/single/model_spec.rb | 13 +++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index d7a3386d..a3be9e7c 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -233,11 +233,11 @@ def format_body_response(body, format) end def format_from_json_compact(body) - JSON.parse(body) + parse_json_payload(body) end def format_from_json_compact_each_row_with_names_and_types(body) - rows = body.split("\n").map { |row| JSON.parse(row) } + rows = body.split("\n").map { |row| parse_json_payload(row) } names, types, *data = rows meta = names.zip(types).map do |name, type| @@ -252,6 +252,10 @@ def format_from_json_compact_each_row_with_names_and_types(body) 'data' => data } end + + def parse_json_payload(payload) + JSON.parse(payload, decimal_class: BigDecimal) + end end end end diff --git a/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb b/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb index 699c4da7..9619bfe5 100644 --- a/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb +++ b/spec/fixtures/migrations/add_sample_data/1_create_sample_table.rb @@ -11,6 +11,7 @@ def up t.datetime :datetime64, precision: 3, null: true t.string :byte_array, null: true t.uuid :relation_uuid + t.decimal :decimal_value, precision: 38, scale: 16, null: true end end end diff --git a/spec/single/model_spec.rb b/spec/single/model_spec.rb index 88c9d54a..e9e46b54 100644 --- a/spec/single/model_spec.rb +++ b/spec/single/model_spec.rb @@ -180,6 +180,19 @@ class ModelPk < ActiveRecord::Base end end + describe 'decimal column type' do + let!(:record1) do + Model.create!(event_name: 'some event', decimal_value: BigDecimal('95891.74')) + end + + # If converted to float, the value would be 9589174.000000001. This happened previously + # due to JSON parsing of numeric values to floats. + it 'keeps precision' do + decimal_value = Model.first.decimal_value + expect(decimal_value).to eq(BigDecimal('95891.74')) + end + end + describe '#settings' do it 'works' do sql = Model.settings(optimize_read_in_order: 1, cast_keep_nullable: 1).to_sql From 6ad665c4fc1ea6d418c861360588decadf88b583 Mon Sep 17 00:00:00 2001 From: Felix Dumit Date: Thu, 4 Apr 2024 00:30:51 -0600 Subject: [PATCH 13/21] Support functions (#120) support functions #120 --- .../clickhouse/schema_statements.rb | 10 ++++++++ .../connection_adapters/clickhouse_adapter.rb | 21 ++++++++++++++++ lib/clickhouse-activerecord/schema_dumper.rb | 13 +++++++++- lib/clickhouse-activerecord/tasks.rb | 7 ++++++ spec/cluster/migration_spec.rb | 16 +++++++++++++ .../1_create_some_function.rb | 7 ++++++ .../1_create_some_function.rb | 10 ++++++++ spec/single/migration_spec.rb | 24 +++++++++++++++++++ 8 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/migrations/dsl_create_function/1_create_some_function.rb create mode 100644 spec/fixtures/migrations/plain_function_creation/1_create_some_function.rb diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index a3be9e7c..3c853cbf 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -57,6 +57,16 @@ def tables(name = nil) result['data'].flatten end + def functions + result = do_system_execute("SELECT name FROM system.functions WHERE origin = 'SQLUserDefined'") + return [] if result.nil? + result['data'].flatten + end + + def show_create_function(function) + do_execute("SELECT create_query FROM system.functions WHERE origin = 'SQLUserDefined' AND name = '#{function}'", format: nil) + end + def table_options(table) sql = show_create_table(table) { options: sql.gsub(/^(?:.*?)(?:ENGINE = (.*?))?( AS SELECT .*?)?$/, '\\1').presence, as: sql.match(/^CREATE (?:.*?) AS (SELECT .*?)$/).try(:[], 1) }.compact diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index 76f85d80..d89cd2ca 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -308,6 +308,11 @@ def create_table(table_name, **options, &block) end end + def create_function(name, body) + fd = "CREATE FUNCTION #{apply_cluster(quote_table_name(name))} AS #{body}" + do_execute(fd, format: nil) + end + # Drops a ClickHouse database. def drop_database(name) #:nodoc: sql = apply_cluster "DROP DATABASE IF EXISTS #{quote_table_name(name)}" @@ -317,6 +322,12 @@ def drop_database(name) #:nodoc: end end + def drop_functions + functions.each do |function| + drop_function(function) + end + end + def rename_table(table_name, new_name) do_execute apply_cluster "RENAME TABLE #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}" end @@ -336,6 +347,16 @@ def drop_table(table_name, options = {}) # :nodoc: end end + def drop_function(name, options = {}) + query = "DROP FUNCTION" + query = "#{query} IF EXISTS " if options[:if_exists] + query = "#{query} #{quote_table_name(name)}" + query = apply_cluster(query) + query = "#{query} SYNC" if options[:sync] + + do_execute(query, format: nil) + end + def add_column(table_name, column_name, type, **options) return if options[:if_not_exists] == true && column_exists?(table_name, column_name, type) diff --git a/lib/clickhouse-activerecord/schema_dumper.rb b/lib/clickhouse-activerecord/schema_dumper.rb index 94981c6f..9ecd6b8d 100644 --- a/lib/clickhouse-activerecord/schema_dumper.rb +++ b/lib/clickhouse-activerecord/schema_dumper.rb @@ -34,8 +34,12 @@ def header(stream) end def tables(stream) + functions = @connection.functions + functions.each do |function| + function(function, stream) + end + sorted_tables = @connection.tables.sort {|a,b| @connection.show_create_table(a).match(/^CREATE\s+(MATERIALIZED\s+)?VIEW/) ? 1 : a <=> b } - sorted_tables.each do |table_name| table(table_name, stream) unless ignored?(table_name) end @@ -119,6 +123,13 @@ def table(table, stream) end end + def function(function, stream) + stream.puts " # FUNCTION: #{function}" + sql = @connection.show_create_function(function) + stream.puts " # SQL: #{sql}" if sql + stream.puts " create_function \"#{function}\", \"#{sql.gsub(/^CREATE FUNCTION (.*?) AS/, '').strip}\"" if sql + end + def format_options(options) if options && options[:options] options[:options] = options[:options].gsub(/^Replicated(.*?)\('[^']+',\s*'[^']+',?\s?([^\)]*)?\)/, "\\1(\\2)") diff --git a/lib/clickhouse-activerecord/tasks.rb b/lib/clickhouse-activerecord/tasks.rb index b8a4842c..4c60a0c8 100644 --- a/lib/clickhouse-activerecord/tasks.rb +++ b/lib/clickhouse-activerecord/tasks.rb @@ -42,8 +42,15 @@ def structure_dump(*args) # sort view to last tables.sort_by! {|table| table.match(/^CREATE\s+(MATERIALIZED\s+)?VIEW/) ? 1 : 0} + # get all functions + functions = connection.execute("SELECT create_query FROM system.functions WHERE origin = 'SQLUserDefined'")['data'].flatten + # put to file File.open(args.first, 'w:utf-8') do |file| + functions.each do |function| + file.puts function + ";\n\n" + end + tables.each do |table| file.puts table + ";\n\n" end diff --git a/spec/cluster/migration_spec.rb b/spec/cluster/migration_spec.rb index 59782e22..28b716c9 100644 --- a/spec/cluster/migration_spec.rb +++ b/spec/cluster/migration_spec.rb @@ -60,6 +60,22 @@ expect(ActiveRecord::Base.connection.tables).not_to include('some_distributed') end end + + context "function" do + after do + ActiveRecord::Base.connection.drop_functions + end + + context 'dsl' do + let(:directory) { 'dsl_create_function' } + + it 'creates a function' do + subject + + expect(ActiveRecord::Base.connection.functions).to match_array(['some_fun']) + end + end + end end context 'with alias in cluster_name' do diff --git a/spec/fixtures/migrations/dsl_create_function/1_create_some_function.rb b/spec/fixtures/migrations/dsl_create_function/1_create_some_function.rb new file mode 100644 index 00000000..8237036d --- /dev/null +++ b/spec/fixtures/migrations/dsl_create_function/1_create_some_function.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateSomeFunction < ActiveRecord::Migration[5.0] + def up + create_function :some_fun, "(x,y) -> x + y" + end +end diff --git a/spec/fixtures/migrations/plain_function_creation/1_create_some_function.rb b/spec/fixtures/migrations/plain_function_creation/1_create_some_function.rb new file mode 100644 index 00000000..a9a558fd --- /dev/null +++ b/spec/fixtures/migrations/plain_function_creation/1_create_some_function.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class CreateSomeFunction < ActiveRecord::Migration[5.0] + def up + sql = <<~SQL + CREATE FUNCTION some_fun AS (x,y) -> x + y + SQL + do_execute(sql, format: nil) + end +end diff --git a/spec/single/migration_spec.rb b/spec/single/migration_spec.rb index 163452b8..07cce336 100644 --- a/spec/single/migration_spec.rb +++ b/spec/single/migration_spec.rb @@ -267,5 +267,29 @@ expect(current_schema['date'].sql_type).to eq('Date') end end + + context 'function creation' do + after do + ActiveRecord::Base.connection.drop_functions + end + + context 'plain' do + let(:directory) { 'plain_function_creation' } + it 'creates a function' do + subject + + expect(ActiveRecord::Base.connection.functions).to match_array(['some_fun']) + end + end + + context 'dsl' do + let(:directory) { 'dsl_create_function' } + it 'creates a function' do + subject + + expect(ActiveRecord::Base.connection.functions).to match_array(['some_fun']) + end + end + end end end From 4495c4c832358041b1f37adc253edfc1f47996d5 Mon Sep 17 00:00:00 2001 From: trumenov <58574669+trumenov@users.noreply.github.com> Date: Mon, 22 Apr 2024 18:33:57 +0300 Subject: [PATCH 14/21] Hotfix/rails71 change column (#132) fixed migration attrs for change_column --- .../connection_adapters/clickhouse/schema_creation.rb | 2 +- .../connection_adapters/clickhouse_adapter.rb | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb index aa20d7c9..64aeb556 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb @@ -108,7 +108,7 @@ def table_modifier_in_create(o) def visit_ChangeColumnDefinition(o) column = o.column - column.sql_type = type_to_sql(column.type, column.options) + column.sql_type = type_to_sql(column.type, **column.options) options = column_options(column) quoted_column_name = quote_column_name(o.name) diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index d89cd2ca..2eaa6e74 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -371,8 +371,8 @@ def remove_column(table_name, column_name, type = nil, **options) 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}) end - def change_column(table_name, column_name, type, options = {}) - 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}) + def change_column(table_name, column_name, type, **options) + 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}) raise "Error parse json response: #{result}" if result.presence && !result.is_a?(Hash) end @@ -441,9 +441,9 @@ def last_inserted_id(result) result end - def change_column_for_alter(table_name, column_name, type, options = {}) + def change_column_for_alter(table_name, column_name, type, **options) td = create_table_definition(table_name) - cd = td.new_column_definition(column_name, type, options) + cd = td.new_column_definition(column_name, type, **options) schema_creation.accept(ChangeColumnDefinition.new(cd, column_name)) end From 5c01a620488f3c7f272f48c476198a0b3649ddc1 Mon Sep 17 00:00:00 2001 From: nixx Date: Thu, 25 Apr 2024 22:14:24 +0300 Subject: [PATCH 15/21] add support indexes --- .docker/docker-compose.cluster.yml | 11 ++++++++ .docker/docker-compose.yml | 3 ++ README.md | 19 ++++++++++++- .../clickhouse/schema_creation.rb | 22 ++++++++++++++- .../clickhouse/schema_definitions.rb | 15 ++++++++++ .../clickhouse/schema_statements.rb | 21 +++++++++++--- .../connection_adapters/clickhouse_adapter.rb | 9 ++++++ spec/cluster/migration_spec.rb | 28 +++++++++++++++---- .../1_create_some_table.rb | 14 ++++++++++ .../2_drop_index.rb | 8 ++++++ .../3_create_index.rb | 8 ++++++ spec/single/migration_spec.rb | 18 ++++++++++++ 12 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 spec/fixtures/migrations/dsl_create_table_with_index/1_create_some_table.rb create mode 100644 spec/fixtures/migrations/dsl_create_table_with_index/2_drop_index.rb create mode 100644 spec/fixtures/migrations/dsl_create_table_with_index/3_create_index.rb diff --git a/.docker/docker-compose.cluster.yml b/.docker/docker-compose.cluster.yml index dd59ccd4..d3ce9996 100644 --- a/.docker/docker-compose.cluster.yml +++ b/.docker/docker-compose.cluster.yml @@ -15,6 +15,9 @@ services: volumes: - './clickhouse/cluster/server1_config.xml:/etc/clickhouse-server/config.xml' - './clickhouse/users.xml:/etc/clickhouse-server/users.xml' + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/8123" + interval: 5s clickhouse2: image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-23.11-alpine}' @@ -29,6 +32,9 @@ services: volumes: - './clickhouse/cluster/server2_config.xml:/etc/clickhouse-server/config.xml' - './clickhouse/users.xml:/etc/clickhouse-server/users.xml' + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/8123" + interval: 5s # Using Nginx as a cluster entrypoint and a round-robin load balancer for HTTP requests nginx: @@ -39,3 +45,8 @@ services: volumes: - './nginx/local.conf:/etc/nginx/conf.d/local.conf' container_name: clickhouse-activerecord-nginx + depends_on: + clickhouse1: + condition: service_healthy + clickhouse2: + condition: service_healthy diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index 1176e95c..5e3ce482 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -12,3 +12,6 @@ services: volumes: - './clickhouse/single/config.xml:/etc/clickhouse-server/config.xml' - './clickhouse/users.xml:/etc/clickhouse-server/users.xml' + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/8123" + interval: 5s diff --git a/README.md b/README.md index d4644f57..d44a601c 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,18 @@ class CreateDataItems < ActiveRecord::Migration end end end - +``` + +Create table with custom column structure: + +``` ruby +class CreateDataItems < ActiveRecord::Migration + def change + create_table "data_items", id: false, options: "MergeTree PARTITION BY toYYYYMM(timestamp) ORDER BY timestamp", force: :cascade do |t| + t.column "timestamp", "DateTime('UTC') CODEC(DoubleDelta, LZ4)" + end + end +end ``` @@ -246,6 +257,12 @@ After checking out the repo, run `bin/setup` to install dependencies. You can al To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +Testing github actions: + +```bash +act +``` + ## Contributing Bug reports and pull requests are welcome on GitHub at [https://github.com/pnixx/clickhouse-activerecord](https://github.com/pnixx/clickhouse-activerecord). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. diff --git a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb index 64aeb556..2d85a372 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb @@ -1,4 +1,3 @@ -# frozen_string_literal: true begin require "active_record/connection_adapters/deduplicable" rescue LoadError => e @@ -93,6 +92,14 @@ def visit_TableDefinition(o) statements = o.columns.map { |c| accept c } statements << accept(o.primary_keys) if o.primary_keys + + if supports_indexes_in_create? + indexes = o.indexes.map do |expression, options| + accept(@conn.add_index_options(o.name, expression, **options)) + end + statements.concat(indexes) + end + create_sql << "(#{statements.join(', ')})" if statements.present? # Attach options for only table or materialized view without TO section add_table_options!(create_sql, o) if !o.view || o.view && o.materialized && !o.to @@ -124,6 +131,19 @@ def visit_ChangeColumnDefinition(o) change_column_sql end + def visit_IndexDefinition(o, create = false) + sql = create ? ["ALTER TABLE #{quote_table_name(o.table)} ADD"] : [] + sql << "INDEX #{quote_column_name(o.name)} (#{o.expression}) TYPE #{o.type} GRANULARITY #{o.granularity}" + sql << "FIRST #{quote_column_name(o.first)}" if o.first + sql << "AFTER #{quote_column_name(o.after)}" if o.after + + sql.join(' ') + end + + def visit_CreateIndexDefinition(o) + visit_IndexDefinition(o.index, true) + end + def current_database ActiveRecord::Base.connection_db_config.database end diff --git a/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb b/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb index e0d7e1d1..63e09aa4 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb @@ -94,6 +94,21 @@ def enum(*args, **options) args.each { |name| column(name, kind, **options.except(:limit)) } end end + + class IndexDefinition + attr_reader :table, :name, :expression, :type, :granularity, :first, :after + + def initialize(table, name, expression, type, granularity, first:, after:) + @table = table + @name = name + @expression = expression + @type = type + @granularity = granularity + @first = first + @after = after + end + + end end end end diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index 3c853cbf..171056a4 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -77,6 +77,19 @@ def indexes(table_name, name = nil) [] end + def add_index_options(table_name, expression, **options) + options.assert_valid_keys(:name, :type, :granularity, :first, :after) + + validate_index_length!(table_name, options[:name]) + + IndexDefinition.new(table_name, options[:name], expression, options[:type], options[:granularity], first: options[:first], after: options[:after]) + end + + def add_index(table_name, expression, **options) + index = add_index_options(apply_cluster(table_name), expression, **options) + execute schema_creation.accept(CreateIndexDefinition.new(index)) + end + def data_sources tables end @@ -84,14 +97,14 @@ def data_sources def do_system_execute(sql, name = nil) log_with_debug(sql, "#{adapter_name} #{name}") do res = request(sql, DEFAULT_RESPONSE_FORMAT) - process_response(res, DEFAULT_RESPONSE_FORMAT) + process_response(res, DEFAULT_RESPONSE_FORMAT, sql) end end def do_execute(sql, name = nil, format: DEFAULT_RESPONSE_FORMAT, settings: {}) log(sql, "#{adapter_name} #{name}") do res = request(sql, format, settings) - process_response(res, format) + process_response(res, format, sql) end end @@ -142,11 +155,11 @@ def apply_format(sql, format) format ? "#{sql} FORMAT #{format}" : sql end - def process_response(res, format) + def process_response(res, format, sql = nil) case res.code.to_i when 200 if res.body.to_s.include?("DB::Exception") - raise ActiveRecord::ActiveRecordError, "Response code: #{res.code}:\n#{res.body}" + raise ActiveRecord::ActiveRecordError, "Response code: #{res.code}:\n#{res.body}#{sql ? "\nQuery: #{sql}" : ''}" else format_body_response(res.body, format) end diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index 2eaa6e74..2c83826b 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -149,6 +149,10 @@ def valid_type?(type) !native_database_types[type].nil? end + def supports_indexes_in_create? + true + end + class << self def extract_limit(sql_type) # :nodoc: case sql_type @@ -386,6 +390,11 @@ def change_column_default(table_name, column_name, default) change_column table_name, column_name, nil, {default: default}.compact end + def remove_index(table_name, name) + query = apply_cluster("ALTER TABLE #{quote_table_name(table_name)}") + execute "#{query} DROP INDEX #{quote_column_name(name)}" + end + def cluster @config[:cluster_name] end diff --git a/spec/cluster/migration_spec.rb b/spec/cluster/migration_spec.rb index 28b716c9..e8f9455b 100644 --- a/spec/cluster/migration_spec.rb +++ b/spec/cluster/migration_spec.rb @@ -68,10 +68,10 @@ context 'dsl' do let(:directory) { 'dsl_create_function' } - + it 'creates a function' do subject - + expect(ActiveRecord::Base.connection.functions).to match_array(['some_fun']) end end @@ -86,9 +86,9 @@ end connection_config = ActiveRecord::Base.connection_db_config.configuration_hash - before(:all) do - ActiveRecord::Base.establish_connection(connection_config.merge(cluster_name: '{cluster}')) - end + before(:all) do + ActiveRecord::Base.establish_connection(connection_config.merge(cluster_name: '{cluster}')) + end after(:all) do ActiveRecord::Base.establish_connection(connection_config) @@ -120,5 +120,23 @@ expect(ActiveRecord::Base.connection.tables).not_to include('some') end end + + context 'create table with index' do + let(:directory) { 'dsl_create_table_with_index' } + + it 'creates a table' do + + expect_any_instance_of(ActiveRecord::ConnectionAdapters::ClickhouseAdapter).to receive(:execute) + .with('ALTER TABLE some ON CLUSTER ' + connection_config[:cluster_name] + ' DROP INDEX idx') + .and_call_original + expect_any_instance_of(ActiveRecord::ConnectionAdapters::ClickhouseAdapter).to receive(:execute) + .with('ALTER TABLE some ON CLUSTER ' + connection_config[:cluster_name] + ' ADD INDEX idx2 (int1 * int2) TYPE set(10) GRANULARITY 4') + .and_call_original + + subject + + expect(ActiveRecord::Base.connection.show_create_table('some')).to include('INDEX idx2 int1 * int2 TYPE set(10) GRANULARITY 4') + end + end end end diff --git a/spec/fixtures/migrations/dsl_create_table_with_index/1_create_some_table.rb b/spec/fixtures/migrations/dsl_create_table_with_index/1_create_some_table.rb new file mode 100644 index 00000000..384f2de9 --- /dev/null +++ b/spec/fixtures/migrations/dsl_create_table_with_index/1_create_some_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateSomeTable < ActiveRecord::Migration[7.1] + def up + create_table :some, options: 'MergeTree PARTITION BY toYYYYMM(date) ORDER BY (date)' do |t| + t.integer :int1, null: false + t.integer :int2, null: false + t.date :date, null: false + + t.index '(int1 * int2, date)', name: 'idx', type: 'minmax', granularity: 3 + end + end +end + diff --git a/spec/fixtures/migrations/dsl_create_table_with_index/2_drop_index.rb b/spec/fixtures/migrations/dsl_create_table_with_index/2_drop_index.rb new file mode 100644 index 00000000..066eae78 --- /dev/null +++ b/spec/fixtures/migrations/dsl_create_table_with_index/2_drop_index.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class DropIndex < ActiveRecord::Migration[7.1] + def up + remove_index :some, 'idx' + end +end + diff --git a/spec/fixtures/migrations/dsl_create_table_with_index/3_create_index.rb b/spec/fixtures/migrations/dsl_create_table_with_index/3_create_index.rb new file mode 100644 index 00000000..0b0c5898 --- /dev/null +++ b/spec/fixtures/migrations/dsl_create_table_with_index/3_create_index.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class CreateIndex < ActiveRecord::Migration[7.1] + def up + add_index :some, 'int1 * int2', name: 'idx2', type: 'set(10)', granularity: 4 + end +end + diff --git a/spec/single/migration_spec.rb b/spec/single/migration_spec.rb index 07cce336..77427e09 100644 --- a/spec/single/migration_spec.rb +++ b/spec/single/migration_spec.rb @@ -209,6 +209,24 @@ expect(ActiveRecord::Base.connection.tables).not_to include('some_view') end end + + context 'with index' do + let(:directory) { 'dsl_create_table_with_index' } + + it 'creates a table' do + quietly { migration_context.up(1) } + + expect(ActiveRecord::Base.connection.show_create_table('some')).to include('INDEX idx (int1 * int2, date) TYPE minmax GRANULARITY 3') + + quietly { migration_context.up(2) } + + expect(ActiveRecord::Base.connection.show_create_table('some')).to_not include('INDEX idx') + + quietly { migration_context.up(3) } + + expect(ActiveRecord::Base.connection.show_create_table('some')).to include('INDEX idx2 int1 * int2 TYPE set(10) GRANULARITY 4') + end + end end end From db87cd28eae30e392e3ecbe08f6134de5971baab Mon Sep 17 00:00:00 2001 From: nixx Date: Fri, 26 Apr 2024 12:42:27 +0300 Subject: [PATCH 16/21] rebuild and clear indexes --- .../clickhouse/schema_creation.rb | 6 +++- .../clickhouse/schema_definitions.rb | 6 ++-- .../clickhouse/schema_statements.rb | 9 ++---- .../connection_adapters/clickhouse_adapter.rb | 27 ++++++++++++++++ spec/single/migration_spec.rb | 32 +++++++++++++++++++ 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb index 2d85a372..7b36ea77 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb @@ -133,7 +133,11 @@ def visit_ChangeColumnDefinition(o) def visit_IndexDefinition(o, create = false) sql = create ? ["ALTER TABLE #{quote_table_name(o.table)} ADD"] : [] - sql << "INDEX #{quote_column_name(o.name)} (#{o.expression}) TYPE #{o.type} GRANULARITY #{o.granularity}" + sql << "INDEX" + sql << "IF NOT EXISTS" if o.if_not_exists + sql << "IF EXISTS" if o.if_exists + sql << "#{quote_column_name(o.name)} (#{o.expression}) TYPE #{o.type}" + sql << "GRANULARITY #{o.granularity}" if o.granularity sql << "FIRST #{quote_column_name(o.first)}" if o.first sql << "AFTER #{quote_column_name(o.after)}" if o.after diff --git a/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb b/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb index 63e09aa4..d02f1bec 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb @@ -96,9 +96,9 @@ def enum(*args, **options) end class IndexDefinition - attr_reader :table, :name, :expression, :type, :granularity, :first, :after + attr_reader :table, :name, :expression, :type, :granularity, :first, :after, :if_exists, :if_not_exists - def initialize(table, name, expression, type, granularity, first:, after:) + def initialize(table, name, expression, type, granularity, first:, after:, if_exists:, if_not_exists:) @table = table @name = name @expression = expression @@ -106,6 +106,8 @@ def initialize(table, name, expression, type, granularity, first:, after:) @granularity = granularity @first = first @after = after + @if_exists = if_exists + @if_not_exists = if_not_exists end end diff --git a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb index 171056a4..82512155 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_statements.rb @@ -78,16 +78,11 @@ def indexes(table_name, name = nil) end def add_index_options(table_name, expression, **options) - options.assert_valid_keys(:name, :type, :granularity, :first, :after) + options.assert_valid_keys(:name, :type, :granularity, :first, :after, :if_not_exists, :if_exists) validate_index_length!(table_name, options[:name]) - IndexDefinition.new(table_name, options[:name], expression, options[:type], options[:granularity], first: options[:first], after: options[:after]) - end - - def add_index(table_name, expression, **options) - index = add_index_options(apply_cluster(table_name), expression, **options) - execute schema_creation.accept(CreateIndexDefinition.new(index)) + IndexDefinition.new(table_name, options[:name], expression, options[:type], options[:granularity], first: options[:first], after: options[:after], if_not_exists: options[:if_not_exists], if_exists: options[:if_exists]) end def data_sources diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index 2c83826b..a5cc37fb 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -390,11 +390,38 @@ def change_column_default(table_name, column_name, default) change_column table_name, column_name, nil, {default: default}.compact end + # Adds index description to tables metadata + def add_index(table_name, expression, **options) + index = add_index_options(apply_cluster(table_name), expression, **options) + execute schema_creation.accept(CreateIndexDefinition.new(index)) + end + + # Removes index description from tables metadata and deletes index files from disk def remove_index(table_name, name) query = apply_cluster("ALTER TABLE #{quote_table_name(table_name)}") execute "#{query} DROP INDEX #{quote_column_name(name)}" end + # Rebuilds the secondary index name for the specified partition_name + def rebuild_index(table_name, name, if_exists: false, partition: nil) + query = [apply_cluster("ALTER TABLE #{quote_table_name(table_name)}")] + query << 'MATERIALIZE INDEX' + query << 'IF EXISTS' if if_exists + query << quote_column_name(name) + query << "IN PARTITION #{quote_column_name(partition)}" if partition + execute query.join(' ') + end + + # Deletes the secondary index files from disk without removing description + def clear_index(table_name, name, if_exists: false, partition: nil) + query = [apply_cluster("ALTER TABLE #{quote_table_name(table_name)}")] + query << 'CLEAR INDEX' + query << 'IF EXISTS' if if_exists + query << quote_column_name(name) + query << "IN PARTITION #{quote_column_name(partition)}" if partition + execute query.join(' ') + end + def cluster @config[:cluster_name] end diff --git a/spec/single/migration_spec.rb b/spec/single/migration_spec.rb index 77427e09..84645399 100644 --- a/spec/single/migration_spec.rb +++ b/spec/single/migration_spec.rb @@ -226,6 +226,38 @@ expect(ActiveRecord::Base.connection.show_create_table('some')).to include('INDEX idx2 int1 * int2 TYPE set(10) GRANULARITY 4') end + + it 'add index if not exists' do + subject + + expect { ActiveRecord::Base.connection.add_index('some', 'int1 + int2', name: 'idx2', type: 'minmax') }.to raise_error(ActiveRecord::ActiveRecordError, include('already exists')) + + ActiveRecord::Base.connection.add_index('some', 'int1 + int2', name: 'idx2', type: 'minmax', if_not_exists: true) + end + + it 'drop index if exists' do + subject + + expect { ActiveRecord::Base.connection.remove_index('some', 'idx3') }.to raise_error(ActiveRecord::ActiveRecordError, include('Cannot find index')) + + ActiveRecord::Base.connection.remove_index('some', 'idx2') + end + + it 'rebuid index' do + subject + + expect { ActiveRecord::Base.connection.rebuild_index('some', 'idx3') }.to raise_error(ActiveRecord::ActiveRecordError, include('Unknown index')) + + expect { ActiveRecord::Base.connection.rebuild_index('some', 'idx3', true) }.to_not raise_error(ActiveRecord::ActiveRecordError) + + ActiveRecord::Base.connection.rebuild_index('some', 'idx2') + end + + it 'clear index' do + subject + + ActiveRecord::Base.connection.clear_index('some', 'idx2') + end end end end From 154bcce37babc7edda275e8dc19a5759c1cd72a8 Mon Sep 17 00:00:00 2001 From: nixx Date: Fri, 26 Apr 2024 13:53:08 +0300 Subject: [PATCH 17/21] dump indexes to schema --- README.md | 14 ++++++++++++- .../connection_adapters/clickhouse_adapter.rb | 1 + lib/clickhouse-activerecord/schema_dumper.rb | 21 +++++++++++++++++-- spec/single/migration_spec.rb | 4 ++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d44a601c..f67365fc 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ false`. The default integer is `UInt32` Example: ``` ruby -class CreateDataItems < ActiveRecord::Migration +class CreateDataItems < ActiveRecord::Migration[7.1] def change create_table "data_items", id: false, options: "VersionedCollapsingMergeTree(sign, version) PARTITION BY toYYYYMM(day) ORDER BY category", force: :cascade do |t| t.date "day", null: false @@ -212,6 +212,18 @@ class CreateDataItems < ActiveRecord::Migration t.integer "sign", limit: 1, unsigned: false, default: -> { "CAST(1, 'Int8')" }, null: false t.integer "version", limit: 8, default: -> { "CAST(toUnixTimestamp(now()), 'UInt64')" }, null: false end + + create_table "with_index", id: false, options: 'MergeTree PARTITION BY toYYYYMM(date) ORDER BY (date)' do |t| + t.integer :int1, null: false + t.integer :int2, null: false + t.date :date, null: false + + t.index '(int1 * int2, date)', name: 'idx', type: 'minmax', granularity: 3 + end + + remove_index :some, 'idx' + + add_index :some, 'int1 * int2', name: 'idx2', type: 'set(10)', granularity: 4 end end ``` diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index a5cc37fb..48e4b9f8 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -391,6 +391,7 @@ def change_column_default(table_name, column_name, default) end # Adds index description to tables metadata + # @link https://clickhouse.com/docs/en/sql-reference/statements/alter/skipping-index def add_index(table_name, expression, **options) index = add_index_options(apply_cluster(table_name), expression, **options) execute schema_creation.accept(CreateIndexDefinition.new(index)) diff --git a/lib/clickhouse-activerecord/schema_dumper.rb b/lib/clickhouse-activerecord/schema_dumper.rb index 9ecd6b8d..54ba2880 100644 --- a/lib/clickhouse-activerecord/schema_dumper.rb +++ b/lib/clickhouse-activerecord/schema_dumper.rb @@ -38,7 +38,7 @@ def tables(stream) functions.each do |function| function(function, stream) end - + sorted_tables = @connection.tables.sort {|a,b| @connection.show_create_table(a).match(/^CREATE\s+(MATERIALIZED\s+)?VIEW/) ? 1 : a <=> b } sorted_tables.each do |table_name| table(table_name, stream) unless ignored?(table_name) @@ -108,7 +108,13 @@ def table(table, stream) end end - indexes_in_create(table, tbl) + indexes = sql.scan(/INDEX \S+ \S+ TYPE .*? GRANULARITY \d+/) + if indexes.any? + tbl.puts '' + indexes.flatten.map!(&:strip).each do |index| + tbl.puts " t.index #{index_parts(index).join(', ')}" + end + end tbl.puts " end" tbl.puts @@ -165,5 +171,16 @@ def prepare_column_options(column) spec[:array] = schema_array(column) spec.merge(super).compact end + + def index_parts(index) + idx = index.match(/^INDEX (?\S+) (?.*?) TYPE (?.*?) GRANULARITY (?\d+)$/) + index_parts = [ + format_index_parts(idx['expr']), + "name: #{format_index_parts(idx['name'])}", + "type: #{format_index_parts(idx['type'])}", + ] + index_parts << "granularity: #{idx['granularity']}" if idx['granularity'] + index_parts + end end end diff --git a/spec/single/migration_spec.rb b/spec/single/migration_spec.rb index 84645399..deedd846 100644 --- a/spec/single/migration_spec.rb +++ b/spec/single/migration_spec.rb @@ -230,9 +230,9 @@ it 'add index if not exists' do subject - expect { ActiveRecord::Base.connection.add_index('some', 'int1 + int2', name: 'idx2', type: 'minmax') }.to raise_error(ActiveRecord::ActiveRecordError, include('already exists')) + expect { ActiveRecord::Base.connection.add_index('some', 'int1 + int2', name: 'idx2', type: 'minmax', granularity: 1) }.to raise_error(ActiveRecord::ActiveRecordError, include('already exists')) - ActiveRecord::Base.connection.add_index('some', 'int1 + int2', name: 'idx2', type: 'minmax', if_not_exists: true) + ActiveRecord::Base.connection.add_index('some', 'int1 + int2', name: 'idx2', type: 'minmax', granularity: 1, if_not_exists: true) end it 'drop index if exists' do From d00fa07f63334925b1f9856266b8edeb94ff7b1b Mon Sep 17 00:00:00 2001 From: nixx Date: Fri, 26 Apr 2024 14:21:29 +0300 Subject: [PATCH 18/21] published 1.0.6 --- lib/clickhouse-activerecord/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clickhouse-activerecord/version.rb b/lib/clickhouse-activerecord/version.rb index a03d7693..bab7d84d 100644 --- a/lib/clickhouse-activerecord/version.rb +++ b/lib/clickhouse-activerecord/version.rb @@ -1,3 +1,3 @@ module ClickhouseActiverecord - VERSION = '1.0.5' + VERSION = '1.0.6' end From 72829dbca0fe1b70bcd31cdd06edf57e4530c62f Mon Sep 17 00:00:00 2001 From: nixx Date: Sat, 27 Apr 2024 08:36:54 +0300 Subject: [PATCH 19/21] published 1.0.7 - fix db tasks --- CHANGELOG.md | 9 +++++++++ lib/clickhouse-activerecord/tasks.rb | 6 +++++- lib/clickhouse-activerecord/version.rb | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02636c16..6cdd42c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +### Version 1.0.7 (Apr 27, 2024) + +* Support table indexes +* Fix non-canonical UUID by [@PauloMiranda98](https://github.com/PauloMiranda98) in (#117) +* Fix precision loss due to JSON float parsing by [@jenskdsgn](https://github.com/jenskdsgn) in (#129) +* Support functions by [@felix-dumit](https://github.com/felix-dumit) in (#120) +* Hotfix/rails71 change column by [@trumenov](https://github.com/trumenov) in (#132) +* Fix DB tasks + ### Version 1.0.5 (Mar 14, 2024) * GitHub workflows diff --git a/lib/clickhouse-activerecord/tasks.rb b/lib/clickhouse-activerecord/tasks.rb index 4c60a0c8..130116a5 100644 --- a/lib/clickhouse-activerecord/tasks.rb +++ b/lib/clickhouse-activerecord/tasks.rb @@ -4,6 +4,10 @@ module ClickhouseActiverecord class Tasks delegate :connection, :establish_connection, to: ActiveRecord::Base + def self.using_database_configurations? + true + end + def initialize(configuration) @configuration = configuration end @@ -50,7 +54,7 @@ def structure_dump(*args) functions.each do |function| file.puts function + ";\n\n" end - + tables.each do |table| file.puts table + ";\n\n" end diff --git a/lib/clickhouse-activerecord/version.rb b/lib/clickhouse-activerecord/version.rb index bab7d84d..0cf70564 100644 --- a/lib/clickhouse-activerecord/version.rb +++ b/lib/clickhouse-activerecord/version.rb @@ -1,3 +1,3 @@ module ClickhouseActiverecord - VERSION = '1.0.6' + VERSION = '1.0.7' end From 34684cf8dc22ac63a09df4287a4386e37508bd20 Mon Sep 17 00:00:00 2001 From: nixx Date: Fri, 3 May 2024 13:40:56 +0300 Subject: [PATCH 20/21] create table similar to other table --- README.md | 16 +++++++++++++--- .../clickhouse/schema_creation.rb | 3 ++- .../connection_adapters/clickhouse_adapter.rb | 6 +++++- lib/clickhouse-activerecord/version.rb | 2 +- .../1_create_some_table.rb | 11 +++++++++++ spec/single/migration_spec.rb | 9 +++++++++ 6 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 spec/fixtures/migrations/dsl_table_buffer_creation/1_create_some_table.rb diff --git a/README.md b/README.md index f67365fc..176e6eba 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ false`. The default integer is `UInt32` Example: -``` ruby +```ruby class CreateDataItems < ActiveRecord::Migration[7.1] def change create_table "data_items", id: false, options: "VersionedCollapsingMergeTree(sign, version) PARTITION BY toYYYYMM(day) ORDER BY category", force: :cascade do |t| @@ -230,8 +230,8 @@ end Create table with custom column structure: -``` ruby -class CreateDataItems < ActiveRecord::Migration +```ruby +class CreateDataItems < ActiveRecord::Migration[7.1] def change create_table "data_items", id: false, options: "MergeTree PARTITION BY toYYYYMM(timestamp) ORDER BY timestamp", force: :cascade do |t| t.column "timestamp", "DateTime('UTC') CODEC(DoubleDelta, LZ4)" @@ -240,6 +240,16 @@ class CreateDataItems < ActiveRecord::Migration end ``` +Create Buffer table with connection database name: + +```ruby +class CreateDataItems < ActiveRecord::Migration[7.1] + def change + create_table :some_buffers, as: :some, options: "Buffer(#{connection.database}, some, 1, 10, 60, 100, 10000, 10000000, 100000000)" + end +end +``` + ### Using replica and cluster params in connection parameters diff --git a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb index 7b36ea77..d3b015f9 100644 --- a/lib/active_record/connection_adapters/clickhouse/schema_creation.rb +++ b/lib/active_record/connection_adapters/clickhouse/schema_creation.rb @@ -88,6 +88,7 @@ def visit_TableDefinition(o) create_sql = +"CREATE#{table_modifier_in_create(o)} #{o.view ? "VIEW" : "TABLE"} " create_sql << "IF NOT EXISTS " if o.if_not_exists create_sql << "#{quote_table_name(o.name)} " + add_as_clause!(create_sql, o) if o.as && !o.view add_to_clause!(create_sql, o) if o.materialized statements = o.columns.map { |c| accept c } @@ -103,7 +104,7 @@ def visit_TableDefinition(o) create_sql << "(#{statements.join(', ')})" if statements.present? # Attach options for only table or materialized view without TO section add_table_options!(create_sql, o) if !o.view || o.view && o.materialized && !o.to - add_as_clause!(create_sql, o) + add_as_clause!(create_sql, o) if o.as && o.view create_sql end diff --git a/lib/active_record/connection_adapters/clickhouse_adapter.rb b/lib/active_record/connection_adapters/clickhouse_adapter.rb index 48e4b9f8..b6faabd8 100644 --- a/lib/active_record/connection_adapters/clickhouse_adapter.rb +++ b/lib/active_record/connection_adapters/clickhouse_adapter.rb @@ -293,7 +293,7 @@ def create_table(table_name, **options, &block) options = apply_replica(table_name, options) td = create_table_definition(apply_cluster(table_name), **options) block.call td if block_given? - td.column(:id, options[:id], null: false) if options[:id].present? && td[:id].blank? + td.column(:id, options[:id], null: false) if options[:id].present? && td[:id].blank? && options[:as].blank? if options[:force] drop_table(table_name, options.merge(if_exists: true)) @@ -431,6 +431,10 @@ def replica @config[:replica_name] end + def database + @config[:database] + end + def use_default_replicated_merge_tree_params? database_engine_atomic? && @config[:use_default_replicated_merge_tree_params] end diff --git a/lib/clickhouse-activerecord/version.rb b/lib/clickhouse-activerecord/version.rb index 0cf70564..e8cd30b3 100644 --- a/lib/clickhouse-activerecord/version.rb +++ b/lib/clickhouse-activerecord/version.rb @@ -1,3 +1,3 @@ module ClickhouseActiverecord - VERSION = '1.0.7' + VERSION = '1.0.8' end diff --git a/spec/fixtures/migrations/dsl_table_buffer_creation/1_create_some_table.rb b/spec/fixtures/migrations/dsl_table_buffer_creation/1_create_some_table.rb new file mode 100644 index 00000000..c50e7a12 --- /dev/null +++ b/spec/fixtures/migrations/dsl_table_buffer_creation/1_create_some_table.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateSomeTable < ActiveRecord::Migration[5.0] + def up + create_table :some do + + end + create_table :some_buffers, as: :some, options: "Buffer(#{connection.database}, some, 1, 10, 60, 100, 10000, 10000000, 100000000)" + end +end + diff --git a/spec/single/migration_spec.rb b/spec/single/migration_spec.rb index deedd846..ac64d7ff 100644 --- a/spec/single/migration_spec.rb +++ b/spec/single/migration_spec.rb @@ -58,6 +58,15 @@ end end + context 'with buffer table' do + let(:directory) { 'dsl_table_buffer_creation' } + it 'creates a table' do + subject + + expect(ActiveRecord::Base.connection.tables).to include('some_buffers') + end + end + context 'with engine' do let(:directory) { 'dsl_table_with_engine_creation' } it 'creates a table' do From 1d9f5443fca32dd92f2348b0330e2b24cb8a0ac9 Mon Sep 17 00:00:00 2001 From: nixx Date: Fri, 3 May 2024 15:32:13 +0300 Subject: [PATCH 21/21] fix schema dumper for buffer table --- lib/clickhouse-activerecord/schema_dumper.rb | 6 ++++-- lib/clickhouse-activerecord/version.rb | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/clickhouse-activerecord/schema_dumper.rb b/lib/clickhouse-activerecord/schema_dumper.rb index 54ba2880..b03839a5 100644 --- a/lib/clickhouse-activerecord/schema_dumper.rb +++ b/lib/clickhouse-activerecord/schema_dumper.rb @@ -90,7 +90,9 @@ def table(table, stream) unless simple table_options = @connection.table_options(table) if table_options.present? - tbl.print ", #{format_options(table_options)}" + table_options = format_options(table_options) + table_options.gsub!(/Buffer\('[^']+'/, 'Buffer(\'#{connection.database}\'') + tbl.print ", #{table_options}" end end @@ -138,7 +140,7 @@ def function(function, stream) def format_options(options) if options && options[:options] - options[:options] = options[:options].gsub(/^Replicated(.*?)\('[^']+',\s*'[^']+',?\s?([^\)]*)?\)/, "\\1(\\2)") + options[:options].gsub!(/^Replicated(.*?)\('[^']+',\s*'[^']+',?\s?([^\)]*)?\)/, "\\1(\\2)") end super end diff --git a/lib/clickhouse-activerecord/version.rb b/lib/clickhouse-activerecord/version.rb index e8cd30b3..efa6f1df 100644 --- a/lib/clickhouse-activerecord/version.rb +++ b/lib/clickhouse-activerecord/version.rb @@ -1,3 +1,3 @@ module ClickhouseActiverecord - VERSION = '1.0.8' + VERSION = '1.0.9' end