diff --git a/Gemfile b/Gemfile index ef85c76..245f236 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,7 @@ gem "testcontainers-redpanda", path: "./redpanda" gem "testcontainers-rabbitmq", path: "./rabbitmq" gem "testcontainers-selenium", path: "./selenium" gem "testcontainers-compose", path: "./compose" +gem "testcontainers-clickhouse", path: "./clickhouse" gem "mysql2", "~> 0.5.3" gem "pg", "~> 1.5" gem "redis", "~> 5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 435ab8d..b5af699 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,12 @@ PATH testcontainers (0.2.0) testcontainers-core (= 0.2.0) +PATH + remote: clickhouse + specs: + testcontainers-clickhouse (0.1.0) + testcontainers-core (~> 0.1) + PATH remote: compose specs: @@ -219,6 +225,7 @@ DEPENDENCIES selenium-webdriver (~> 4.1.0) standard (~> 1.3) testcontainers! + testcontainers-clickhouse! testcontainers-compose! testcontainers-core! testcontainers-elasticsearch! diff --git a/clickhouse/CHANGELOG.md b/clickhouse/CHANGELOG.md new file mode 100644 index 0000000..d6adcce --- /dev/null +++ b/clickhouse/CHANGELOG.md @@ -0,0 +1,5 @@ +## [0.1.0] - 2026-03-29 + +### Added + +- Initial release of the ClickHouse module diff --git a/clickhouse/Gemfile b/clickhouse/Gemfile new file mode 100644 index 0000000..58739d6 --- /dev/null +++ b/clickhouse/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in testcontainers-clickhouse.gemspec +gemspec + +# Use the latest version of testcontainers-core from the local path +gem "testcontainers-core", path: "../core" diff --git a/clickhouse/Gemfile.lock b/clickhouse/Gemfile.lock new file mode 100644 index 0000000..54b4f6d --- /dev/null +++ b/clickhouse/Gemfile.lock @@ -0,0 +1,129 @@ +PATH + remote: ../core + specs: + testcontainers-core (0.2.0) + base64 (~> 0.3) + docker-api (~> 2.4) + java-properties (~> 0.3.0) + +PATH + remote: . + specs: + testcontainers-clickhouse (0.1.0) + testcontainers-core (~> 0.1) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + base64 (0.3.0) + docker-api (2.4.0) + excon (>= 0.64.0) + multi_json + excon (1.4.2) + logger + java-properties (0.3.0) + json (2.19.3) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + minitest (5.27.0) + minitest-hooks (1.5.3) + minitest (> 5.3) + multi_json (1.19.1) + net-http (0.9.1) + uri (>= 0.11.1) + parallel (1.27.0) + parser (3.3.10.2) + ast (~> 2.4.1) + racc + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + regexp_parser (2.11.3) + rubocop (1.84.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + standard (1.54.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.84.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + minitest (~> 5.0) + minitest-hooks (~> 1.5) + net-http + rake (~> 13.0) + standard (~> 1.3) + testcontainers-clickhouse! + testcontainers-core! + +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + docker-api (2.4.0) sha256=824be734f4cc8718189be9c8e795b6414acbbf7e8b082a06f959a27dd8dd63e6 + excon (1.4.2) sha256=32d8d8eda619717d9b8043b4675e096fb5c2139b080e2ad3b267f88c545aaa35 + java-properties (0.3.0) sha256=0a9fdda90c25ba9ba4de0e242d954a5688629652b592aab66ed54e2b16b93093 + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + minitest-hooks (1.5.3) sha256=ef50dd3bf47e6d1646befc358c640c71ca41f5650f0036b4c69929a44d6f32c4 + multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + standard (1.54.0) sha256=7a4b08f83d9893083c8f03bc486f0feeb6a84d48233b40829c03ef4767ea0100 + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 + testcontainers-clickhouse (0.1.0) + testcontainers-core (0.2.0) + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + +BUNDLED WITH + 4.0.7 diff --git a/clickhouse/LICENSE.txt b/clickhouse/LICENSE.txt new file mode 100644 index 0000000..a7d0470 --- /dev/null +++ b/clickhouse/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 Guillermo Iguaran + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/clickhouse/Rakefile b/clickhouse/Rakefile new file mode 100644 index 0000000..b8766a2 --- /dev/null +++ b/clickhouse/Rakefile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] +end + +require "standard/rake" + +task default: %i[test standard] diff --git a/clickhouse/lib/testcontainers/clickhouse.rb b/clickhouse/lib/testcontainers/clickhouse.rb new file mode 100644 index 0000000..82f5db6 --- /dev/null +++ b/clickhouse/lib/testcontainers/clickhouse.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require_relative "clickhouse/version" +require "testcontainers" + +module Testcontainers + # ClickhouseContainer class is used to manage containers that runs a Clickhouse server + # + # @attr_reader [String] username used by the container + # @attr_reader [String] password used by the container + # @attr_reader [String] database used by the container + class ClickhouseContainer < ::Testcontainers::DockerContainer + # Default ports used by the container + CLICKHOUSE_DEFAULT_PORT = 9000 + CLICKHOUSE_DEFAULT_HTTP_PORT = 8123 + + # Default image used by the container + CLICKHOUSE_DEFAULT_IMAGE = "clickhouse/clickhouse-server:latest" + + # Default credentials used by the container + CLICKHOUSE_DEFAULT_USER = "default" + CLICKHOUSE_DEFAULT_PASS = "password" + CLICKHOUSE_DB = "default" + + attr_reader :username, :password, :database + + # Default "wait for" strategy + WAIT_FOR_PROC = ->(container) { + container.wait_for_http(container_port: 8123, timeout: 30, interval: 1.0, path: "/", status: 200) + } + + # Initializes a new instance of ClickhouseContainer + # + # @param image [String] the image to use + # @param username [String] the username to use + # @param password [String] the password to use + # @param database [String] + # @param kwargs [Hash] the options to pass to the container. See {DockerContainer#initialize} + # @return [ClickhouseContainer] a new instance of ClickhouseContainer + def initialize(image = CLICKHOUSE_DEFAULT_IMAGE, username: nil, password: nil, database: nil, **kwargs) + super(image, wait_for: WAIT_FOR_PROC, exposed_ports: [CLICKHOUSE_DEFAULT_PORT, CLICKHOUSE_DEFAULT_HTTP_PORT], **kwargs) + @username = username || ENV.fetch("CLICKHOUSE_USER", CLICKHOUSE_DEFAULT_USER) + @password = password || ENV.fetch("CLICKHOUSE_PASSWORD", CLICKHOUSE_DEFAULT_PASS) + @database = database || ENV.fetch("CLICKHOUSE_DB", CLICKHOUSE_DB) + end + + # Starts the container + # + # @return [ClickhouseContainer] self + def start + _configure + super + end + + # Returns the native TCP port used to connect to the container + # + # @return [Integer] the port used by the container + def port + CLICKHOUSE_DEFAULT_PORT + end + alias_method :tcp_port, :port + + # Returns the HTTP port used to connect to the container via HTTP/HTTPS + # + # @return [Integer] the HTTP/HTTPS port used by the container + def http_port + CLICKHOUSE_DEFAULT_HTTP_PORT + end + + # Returns the clickhouse connection url (e.g. clickhouse://user:password@host:port/database) + # + # @param protocol [String] the protocol to use in the string (default: "clickhouse://") + # @param username [String] the username to use in the string (default: @username) + # @param password [String] the password to use in the string (default: @password) + # @param database [String] the database to use in the string (default: @database) + # @return [String] the clickhouse url + # @raise [ConnectionError] If the connection to the Docker daemon fails. + # @raise [ContainerNotStartedError] If the container has not been started. + def clickhouse_url(protocol: "clickhouse://", username: nil, password: nil, database: nil) + username ||= @username + password ||= @password + database ||= @database + database = "/#{database}" unless database.start_with?("/") + + # clickhouse://user:pass@host:9000/database + "#{protocol}#{username}:#{password}@#{host}:#{mapped_port(port)}#{database}" + end + + alias_method :connection_url, :clickhouse_url + + # Returns the clickhouse connection url (e.g. http://user:password@host:port) + # + # @param protocol [String] the protocol to use in the string (default: "http") + # @return [String] the url for the management UI. Returns nil if the management UI is not available. + # @raise [ConnectionError] If the connection to the Docker daemon fails. + # @raise [ContainerNotStartedError] If the container has not been started. + def clickhouse_http_url(protocol: "http") + port = mapped_port(http_port) + port.nil? ? nil : "#{protocol}://#{username}:#{password}@#{host}:#{port}" + end + + # Sets the database to use + # + # @param database [String] the database to use + # @return [ClickhouseContainer] self + def with_database(database) + @database = database + self + end + + # Sets the username to use + # + # @param username [String] the username to use + # @return [ClickhouseContainer] self + def with_username(username) + @username = username + self + end + + # Sets the password to use + # + # @param password [String] the password to use + # @return [ClickhouseContainer] self + def with_password(password) + @password = password + self + end + + # Returns the mapped TCP port + # + # @return [Integer] The container's mapped TCP port. + # @raise [ConnectionError] If the connection to the Docker daemon fails. + def first_mapped_port + raise ContainerNotStartedError unless @_container + mapped_port(port) + end + + private + + def _configure + add_env("CLICKHOUSE_USER", @username) + add_env("CLICKHOUSE_PASSWORD", @password) + add_env("CLICKHOUSE_DB", @database) + end + end +end diff --git a/clickhouse/lib/testcontainers/clickhouse/version.rb b/clickhouse/lib/testcontainers/clickhouse/version.rb new file mode 100644 index 0000000..6d1ee84 --- /dev/null +++ b/clickhouse/lib/testcontainers/clickhouse/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Testcontainers + module Clickhouse + VERSION = "0.1.0" + end +end diff --git a/clickhouse/test/clickhouse_container_test.rb b/clickhouse/test/clickhouse_container_test.rb new file mode 100644 index 0000000..9d9f7b4 --- /dev/null +++ b/clickhouse/test/clickhouse_container_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "net/http" +require "test_helper" + +class ClickhouseContainerTest < TestcontainersTest + def before_all + super + + @container = Testcontainers::ClickhouseContainer.new + @container.start + @host = @container.host + @database = @container.database + @port = @container.mapped_port(@container.port) + @http_port = @container.mapped_port(@container.http_port) + end + + def after_all + if @container&.exists? + @container&.stop if @container&.running? + @container&.remove + end + + super + end + + def test_it_returns_the_default_image + assert_equal "clickhouse/clickhouse-server:latest", @container.image + end + + def test_it_supports_custom_image + container = Testcontainers::ClickhouseContainer.new("clickhouse/clickhouse-server:26.1") + assert_equal "clickhouse/clickhouse-server:26.1", container.image + end + + def test_it_returns_the_default_http_port + assert_equal 8123, @container.http_port + end + + def test_it_returns_the_default_port + assert_equal 9000, @container.port + end + + def test_it_returns_the_default_tcp_port + assert_equal 9000, @container.tcp_port + end + + def test_it_has_the_default_ports_mapped + assert @container.mapped_port(8123) + assert @container.mapped_port(9000) + end + + def test_it_returns_the_default_clickhouse_url + assert_equal "clickhouse://default:password@#{@host}:#{@port}/default", @container.clickhouse_url + end + + def test_it_returns_the_default_clickhouse_http_url + assert_equal "http://default:password@#{@host}:#{@http_port}", @container.clickhouse_http_url + end + + def test_it_returns_the_clickhouse_http_url_with_custom_protocol + assert_equal "https://default:password@#{@host}:#{@http_port}", @container.clickhouse_http_url(protocol: "https") + end + + def test_it_returns_the_clickhouse_url_with_custom_username_and_password + assert_equal "clickhouse://foo:bar@#{@host}:#{@port}/#{@database}", @container.clickhouse_url(username: "foo", password: "bar") + end + + def test_it_is_reachable + uri = URI("#{@container.clickhouse_http_url}/?query=SELECT+1") + uri.user = nil + uri.password = nil + response = Net::HTTP.start(uri.host, uri.port) do |http| + req = Net::HTTP::Get.new(uri) + req.basic_auth(@container.username, @container.password) + http.request(req) + end + assert_equal "200", response.code + assert_equal "1\n", response.body + end +end diff --git a/clickhouse/test/test_helper.rb b/clickhouse/test/test_helper.rb new file mode 100644 index 0000000..42bbcfc --- /dev/null +++ b/clickhouse/test/test_helper.rb @@ -0,0 +1,9 @@ +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "testcontainers/clickhouse" + +require "minitest/autorun" +require "minitest/hooks/test" + +class TestcontainersTest < Minitest::Test + include Minitest::Hooks +end diff --git a/clickhouse/testcontainers-clickhouse.gemspec b/clickhouse/testcontainers-clickhouse.gemspec new file mode 100644 index 0000000..67f6f50 --- /dev/null +++ b/clickhouse/testcontainers-clickhouse.gemspec @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "lib/testcontainers/clickhouse/version" + +Gem::Specification.new do |spec| + spec.name = "testcontainers-clickhouse" + spec.version = Testcontainers::Clickhouse::VERSION + spec.authors = ["Vitaly Slobodin", "Guillermo Iguaran"] + spec.email = ["vitaliy.slobodin@gmail.com", "guilleiguaran@gmail.com"] + + spec.summary = "Testcontainers for Ruby: clickhouse module" + spec.description = "Testcontainers makes it easy to create and clean up container-based dependencies for automated tests." + spec.homepage = "https://github.com/testcontainers/testcontainers-ruby" + spec.license = "MIT" + spec.required_ruby_version = ">= 2.6.0" + + spec.metadata["homepage_uri"] = "#{spec.homepage}/blob/main/clickhouse" + spec.metadata["source_code_uri"] = "#{spec.homepage}/blob/main/clickhouse" + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/clickhouse/CHANGELOG.md" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)}) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "testcontainers-core", "~> 0.1" + + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "minitest", "~> 5.0" + spec.add_development_dependency "minitest-hooks", "~> 1.5" + spec.add_development_dependency "standard", "~> 1.3" + spec.add_development_dependency "net-http" + + # For more information and examples about making a new gem, check out our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/examples/clickhouse_rspec.rb b/examples/clickhouse_rspec.rb new file mode 100644 index 0000000..642ab57 --- /dev/null +++ b/examples/clickhouse_rspec.rb @@ -0,0 +1,57 @@ +require "bundler/inline" + +gemfile do + source "https://rubygems.org" + gem "testcontainers-core", path: "../core" + gem "testcontainers-clickhouse", path: "../clickhouse" + + gem "rspec" + gem "clickhouse-activerecord" + gem "activerecord", "~> 7.2" +end + +require "active_record" +require "clickhouse-activerecord" +require "rspec" +require "rspec/autorun" + +RSpec.configure do |config| + config.add_setting :clickhouse_container, default: nil + + config.before(:suite) do + config.clickhouse_container = Testcontainers::ClickhouseContainer.new.start + end + + config.after(:suite) do + config.clickhouse_container&.stop if config.clickhouse_container&.running? + config.clickhouse_container&.remove + end +end + +class Event < ActiveRecord::Base + self.abstract_class = true +end + +RSpec.describe "Clickhouse" do + before(:all) do + container = RSpec.configuration.clickhouse_container + ActiveRecord::Base.establish_connection( + adapter: "clickhouse", + host: container.host, + port: container.mapped_port(container.http_port), + database: container.database, + username: container.username, + password: container.password + ) + + ActiveRecord::Base.connection.create_table :events, id: false, options: "ENGINE = Memory" do |t| + t.string :name, null: false + end + end + + it "inserts and queries rows" do + Event.create!(name: "test") + expect(Event.count).to eq(1) + expect(Event.first.name).to eq("test") + end +end