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
+
+ 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
+
+ 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
+
+ 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