From 98169fdd4213a350c44cb4652acacf70e4c45563 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 29 Oct 2025 10:19:01 -0400 Subject: [PATCH 01/49] Move tests into adapter folders --- .../20250213005959_add_age_to_users.rb | 0 .../20250830152220_create_posts.rb | 0 ...0250830170325_add_announcement_to_users.rb | 0 ...0250830175957_add_announceable_to_users.rb | 0 .../{ => sqlite}/primary_db/database.yml | 0 .../{ => sqlite}/primary_db/primary_record.rb | 0 .../primary_db/secondary_record.rb | 0 .../20250203191207_create_announcements.rb | 0 .../primary_db/subtenant_record.rb | 0 .../20250203191115_create_users.rb | 0 .../primary_named_db/database.yml | 0 .../primary_named_db/primary_record.rb | 0 .../primary_named_db/secondary_record.rb | 0 .../20250203191207_create_announcements.rb | 0 .../20250203191115_create_users.rb | 0 .../{ => sqlite}/primary_uri_db/database.yml | 0 .../primary_uri_db/primary_record.rb | 0 .../primary_uri_db/secondary_record.rb | 0 .../20250203191207_create_announcements.rb | 0 .../20250203191115_create_users.rb | 0 test/scenarios/{ => sqlite}/schema.rb | 0 test/scenarios/{ => sqlite}/schema_cache.yml | 0 .../{ => sqlite}/secondary_db/database.yml | 0 .../secondary_db/primary_record.rb | 0 .../secondary_db/secondary_record.rb | 0 .../20250203191207_create_announcements.rb | 0 .../20250203191115_create_users.rb | 0 test/test_helper.rb | 97 ++++++++++++++++--- test/unit/database_configurations_test.rb | 4 +- test/unit/tenant_test.rb | 34 ++++--- 30 files changed, 106 insertions(+), 29 deletions(-) rename test/scenarios/{ => sqlite}/20250213005959_add_age_to_users.rb (100%) rename test/scenarios/{ => sqlite}/20250830152220_create_posts.rb (100%) rename test/scenarios/{ => sqlite}/20250830170325_add_announcement_to_users.rb (100%) rename test/scenarios/{ => sqlite}/20250830175957_add_announceable_to_users.rb (100%) rename test/scenarios/{ => sqlite}/primary_db/database.yml (100%) rename test/scenarios/{ => sqlite}/primary_db/primary_record.rb (100%) rename test/scenarios/{ => sqlite}/primary_db/secondary_record.rb (100%) rename test/scenarios/{ => sqlite}/primary_db/shared_migrations/20250203191207_create_announcements.rb (100%) rename test/scenarios/{ => sqlite}/primary_db/subtenant_record.rb (100%) rename test/scenarios/{ => sqlite}/primary_db/tenanted_migrations/20250203191115_create_users.rb (100%) rename test/scenarios/{ => sqlite}/primary_named_db/database.yml (100%) rename test/scenarios/{ => sqlite}/primary_named_db/primary_record.rb (100%) rename test/scenarios/{ => sqlite}/primary_named_db/secondary_record.rb (100%) rename test/scenarios/{ => sqlite}/primary_named_db/shared_migrations/20250203191207_create_announcements.rb (100%) rename test/scenarios/{ => sqlite}/primary_named_db/tenanted_migrations/20250203191115_create_users.rb (100%) rename test/scenarios/{ => sqlite}/primary_uri_db/database.yml (100%) rename test/scenarios/{ => sqlite}/primary_uri_db/primary_record.rb (100%) rename test/scenarios/{ => sqlite}/primary_uri_db/secondary_record.rb (100%) rename test/scenarios/{ => sqlite}/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb (100%) rename test/scenarios/{ => sqlite}/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb (100%) rename test/scenarios/{ => sqlite}/schema.rb (100%) rename test/scenarios/{ => sqlite}/schema_cache.yml (100%) rename test/scenarios/{ => sqlite}/secondary_db/database.yml (100%) rename test/scenarios/{ => sqlite}/secondary_db/primary_record.rb (100%) rename test/scenarios/{ => sqlite}/secondary_db/secondary_record.rb (100%) rename test/scenarios/{ => sqlite}/secondary_db/shared_migrations/20250203191207_create_announcements.rb (100%) rename test/scenarios/{ => sqlite}/secondary_db/tenanted_migrations/20250203191115_create_users.rb (100%) diff --git a/test/scenarios/20250213005959_add_age_to_users.rb b/test/scenarios/sqlite/20250213005959_add_age_to_users.rb similarity index 100% rename from test/scenarios/20250213005959_add_age_to_users.rb rename to test/scenarios/sqlite/20250213005959_add_age_to_users.rb diff --git a/test/scenarios/20250830152220_create_posts.rb b/test/scenarios/sqlite/20250830152220_create_posts.rb similarity index 100% rename from test/scenarios/20250830152220_create_posts.rb rename to test/scenarios/sqlite/20250830152220_create_posts.rb diff --git a/test/scenarios/20250830170325_add_announcement_to_users.rb b/test/scenarios/sqlite/20250830170325_add_announcement_to_users.rb similarity index 100% rename from test/scenarios/20250830170325_add_announcement_to_users.rb rename to test/scenarios/sqlite/20250830170325_add_announcement_to_users.rb diff --git a/test/scenarios/20250830175957_add_announceable_to_users.rb b/test/scenarios/sqlite/20250830175957_add_announceable_to_users.rb similarity index 100% rename from test/scenarios/20250830175957_add_announceable_to_users.rb rename to test/scenarios/sqlite/20250830175957_add_announceable_to_users.rb diff --git a/test/scenarios/primary_db/database.yml b/test/scenarios/sqlite/primary_db/database.yml similarity index 100% rename from test/scenarios/primary_db/database.yml rename to test/scenarios/sqlite/primary_db/database.yml diff --git a/test/scenarios/primary_db/primary_record.rb b/test/scenarios/sqlite/primary_db/primary_record.rb similarity index 100% rename from test/scenarios/primary_db/primary_record.rb rename to test/scenarios/sqlite/primary_db/primary_record.rb diff --git a/test/scenarios/primary_db/secondary_record.rb b/test/scenarios/sqlite/primary_db/secondary_record.rb similarity index 100% rename from test/scenarios/primary_db/secondary_record.rb rename to test/scenarios/sqlite/primary_db/secondary_record.rb diff --git a/test/scenarios/primary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/primary_db/shared_migrations/20250203191207_create_announcements.rb similarity index 100% rename from test/scenarios/primary_db/shared_migrations/20250203191207_create_announcements.rb rename to test/scenarios/sqlite/primary_db/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/primary_db/subtenant_record.rb b/test/scenarios/sqlite/primary_db/subtenant_record.rb similarity index 100% rename from test/scenarios/primary_db/subtenant_record.rb rename to test/scenarios/sqlite/primary_db/subtenant_record.rb diff --git a/test/scenarios/primary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/primary_db/tenanted_migrations/20250203191115_create_users.rb similarity index 100% rename from test/scenarios/primary_db/tenanted_migrations/20250203191115_create_users.rb rename to test/scenarios/sqlite/primary_db/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/primary_named_db/database.yml b/test/scenarios/sqlite/primary_named_db/database.yml similarity index 100% rename from test/scenarios/primary_named_db/database.yml rename to test/scenarios/sqlite/primary_named_db/database.yml diff --git a/test/scenarios/primary_named_db/primary_record.rb b/test/scenarios/sqlite/primary_named_db/primary_record.rb similarity index 100% rename from test/scenarios/primary_named_db/primary_record.rb rename to test/scenarios/sqlite/primary_named_db/primary_record.rb diff --git a/test/scenarios/primary_named_db/secondary_record.rb b/test/scenarios/sqlite/primary_named_db/secondary_record.rb similarity index 100% rename from test/scenarios/primary_named_db/secondary_record.rb rename to test/scenarios/sqlite/primary_named_db/secondary_record.rb diff --git a/test/scenarios/primary_named_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/primary_named_db/shared_migrations/20250203191207_create_announcements.rb similarity index 100% rename from test/scenarios/primary_named_db/shared_migrations/20250203191207_create_announcements.rb rename to test/scenarios/sqlite/primary_named_db/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/primary_named_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/primary_named_db/tenanted_migrations/20250203191115_create_users.rb similarity index 100% rename from test/scenarios/primary_named_db/tenanted_migrations/20250203191115_create_users.rb rename to test/scenarios/sqlite/primary_named_db/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/primary_uri_db/database.yml b/test/scenarios/sqlite/primary_uri_db/database.yml similarity index 100% rename from test/scenarios/primary_uri_db/database.yml rename to test/scenarios/sqlite/primary_uri_db/database.yml diff --git a/test/scenarios/primary_uri_db/primary_record.rb b/test/scenarios/sqlite/primary_uri_db/primary_record.rb similarity index 100% rename from test/scenarios/primary_uri_db/primary_record.rb rename to test/scenarios/sqlite/primary_uri_db/primary_record.rb diff --git a/test/scenarios/primary_uri_db/secondary_record.rb b/test/scenarios/sqlite/primary_uri_db/secondary_record.rb similarity index 100% rename from test/scenarios/primary_uri_db/secondary_record.rb rename to test/scenarios/sqlite/primary_uri_db/secondary_record.rb diff --git a/test/scenarios/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb similarity index 100% rename from test/scenarios/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb rename to test/scenarios/sqlite/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb similarity index 100% rename from test/scenarios/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb rename to test/scenarios/sqlite/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/schema.rb b/test/scenarios/sqlite/schema.rb similarity index 100% rename from test/scenarios/schema.rb rename to test/scenarios/sqlite/schema.rb diff --git a/test/scenarios/schema_cache.yml b/test/scenarios/sqlite/schema_cache.yml similarity index 100% rename from test/scenarios/schema_cache.yml rename to test/scenarios/sqlite/schema_cache.yml diff --git a/test/scenarios/secondary_db/database.yml b/test/scenarios/sqlite/secondary_db/database.yml similarity index 100% rename from test/scenarios/secondary_db/database.yml rename to test/scenarios/sqlite/secondary_db/database.yml diff --git a/test/scenarios/secondary_db/primary_record.rb b/test/scenarios/sqlite/secondary_db/primary_record.rb similarity index 100% rename from test/scenarios/secondary_db/primary_record.rb rename to test/scenarios/sqlite/secondary_db/primary_record.rb diff --git a/test/scenarios/secondary_db/secondary_record.rb b/test/scenarios/sqlite/secondary_db/secondary_record.rb similarity index 100% rename from test/scenarios/secondary_db/secondary_record.rb rename to test/scenarios/sqlite/secondary_db/secondary_record.rb diff --git a/test/scenarios/secondary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/secondary_db/shared_migrations/20250203191207_create_announcements.rb similarity index 100% rename from test/scenarios/secondary_db/shared_migrations/20250203191207_create_announcements.rb rename to test/scenarios/sqlite/secondary_db/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/secondary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/secondary_db/tenanted_migrations/20250203191115_create_users.rb similarity index 100% rename from test/scenarios/secondary_db/tenanted_migrations/20250203191115_create_users.rb rename to test/scenarios/sqlite/secondary_db/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/test_helper.rb b/test/test_helper.rb index e8cf74c9..61c45b68 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -24,6 +24,7 @@ class TestSuiteRailtie < ::Rails::Railtie require_relative "../lib/active_record/tenanted" require_relative "dummy/config/environment" +require "erb" require "minitest/spec" require "minitest/mock" @@ -62,11 +63,16 @@ def for_each_db_scenario(s = all_scenarios, &block) end end - def for_each_scenario(s = all_scenarios, except: {}, &block) + def for_each_scenario(s = all_scenarios, except: {}, only: {}, &block) s.each do |db_scenario, model_scenarios| with_db_scenario(db_scenario) do model_scenarios.each do |model_scenario| + scenario_name = db_scenario.to_s.split("/").last + adapter = db_scenario.to_s.include?("/") ? db_scenario.to_s.split("/").first.to_sym : nil + + next if only.present? && only.dig(:adapter) != adapter next if except[db_scenario.to_sym]&.include?(model_scenario.to_sym) + next if except[scenario_name.to_sym]&.include?(model_scenario.to_sym) with_model_scenario(model_scenario, &block) end end @@ -74,29 +80,54 @@ def for_each_scenario(s = all_scenarios, except: {}, &block) end def all_scenarios - Dir.glob(File.join(__dir__, "scenarios", "*", "database.yml")) + filter_adapter = ENV["TEST_ADAPTER"] + Dir.glob(File.join(__dir__, "scenarios", "*", "*", "database.yml")) .each_with_object({}) do |db_config_path, scenarios| db_config_dir = File.dirname(db_config_path) + db_adapter = File.basename(File.dirname(db_config_dir)) db_scenario = File.basename(db_config_dir) model_files = Dir.glob(File.join(db_config_dir, "*.rb")) - scenarios[db_scenario] = model_files.map { File.basename(_1, ".*") } + next if filter_adapter && db_adapter != filter_adapter + + scenarios["#{db_adapter}/#{db_scenario}"] = model_files.map { File.basename(_1, ".*") } end end def with_db_scenario(db_scenario, &block) - db_config_path = File.join(__dir__, "scenarios", db_scenario.to_s, "database.yml") + db_adapter, db_name = db_scenario.to_s.split("/", 2) + + if db_name.nil? + db_name = db_adapter + matching_scenarios = all_scenarios.keys.select { |key| key.to_s.end_with?("/#{db_name}") } + raise "Could not find scenario: #{db_name}" if matching_scenarios.empty? + + if matching_scenarios.size > 1 + matching_scenarios.each do |scenario| + with_db_scenario(scenario, &block) + end + return + end + + db_adapter, db_name = matching_scenarios.first.to_s.split("/", 2) + end + + db_config_path = File.join(__dir__, "scenarios", db_adapter, db_name, "database.yml") raise "Could not find scenario db config: #{db_config_path}" unless File.exist?(db_config_path) - describe "scenario::#{db_scenario}" do + describe "scenario::#{db_adapter}/#{db_name}" do @db_config_dir = db_config_dir = File.dirname(db_config_path) let(:ephemeral_path) { Dir.mktmpdir("test-activerecord-tenanted-") } let(:storage_path) { File.join(ephemeral_path, "storage") } let(:db_path) { File.join(ephemeral_path, "db") } - let(:db_scenario) { db_scenario.to_sym } - let(:db_config_yml) { sprintf(File.read(db_config_path), storage: storage_path, db_path: db_path) } - let(:db_config) { YAML.load(db_config_yml) } + let(:db_adapter) { "#{db_adapter}" } + let(:db_scenario) { db_name.to_sym } + let(:db_config_yml) do + erb_content = ERB.new(File.read(db_config_path)).result(binding) + sprintf(erb_content, storage: storage_path, db_path: db_path) + end + let(:db_config) { YAML.load(db_config_yml, aliases: true) } setup do FileUtils.mkdir(db_path) @@ -119,6 +150,8 @@ def with_db_scenario(db_scenario, &block) end teardown do + cleanup_shared_databases + cleanup_tenant_databases ActiveRecord::Migration.verbose = @migration_verbose_was ActiveRecord::Base.configurations = @old_configurations ActiveRecord::Tasks::DatabaseTasks.db_dir = @old_db_dir @@ -207,12 +240,14 @@ def base_config end def with_schema_dump_file - FileUtils.cp "test/scenarios/schema.rb", + schema_file = Dir.glob("test/scenarios/*/schema.rb").first + FileUtils.cp schema_file, ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(base_config) end def with_schema_cache_dump_file - FileUtils.cp "test/scenarios/schema_cache.yml", + cache_file = Dir.glob("test/scenarios/*/schema_cache.yml").first + FileUtils.cp cache_file, ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(base_config) end @@ -221,7 +256,7 @@ def with_new_migration_file end def with_migration(file) - FileUtils.cp File.join("test", "scenarios", file), File.join(db_path, "tenanted_migrations") + FileUtils.cp File.join("test", "scenarios", db_adapter, file), File.join(db_path, "tenanted_migrations") end def assert_same_elements(expected, actual) @@ -247,6 +282,46 @@ def capture_rails_log end private + def cleanup_tenant_databases + base_config = all_configs.find { |c| c.configuration_hash[:tenanted] } + return unless base_config + + tenants = base_config.tenants + tenants.each do |tenant_name| + adapter = base_config.new_tenant_config(tenant_name).config_adapter + adapter.drop_database + rescue => e + Rails.logger.warn "Failed to cleanup tenant database #{tenant_name}: #{e.message}" + end + end + + def cleanup_shared_databases + shared_configs = all_configs.reject { |c| c.configuration_hash[:tenanted] || c.database.blank? } + shared_configs.each do |config| + if db_adapter == "mysql" + ActiveRecord::Tasks::DatabaseTasks.drop(config) + ActiveRecord::Tasks::DatabaseTasks.create(config) + ActiveRecord::Tasks::DatabaseTasks.migrate(config) + else + pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + config.name, + role: ActiveRecord.writing_role, + shard: ActiveRecord::Base.default_shard, + strict: false + ) + next unless pool + + conn = pool.connection + tables = conn.tables - [ "ar_internal_metadata", "schema_migrations" ] + tables.each do |table| + conn.execute("DELETE FROM #{conn.quote_table_name(table)}") + end + end + rescue => e + Rails.logger.warn "Failed to cleanup shared database #{config.name}: #{e.message}" + end + end + def create_fake_record # emulate models like ActiveStorage::Record that inherit directly from AR::Base Object.const_set(:FakeRecord, Class.new(ActiveRecord::Base)) diff --git a/test/unit/database_configurations_test.rb b/test/unit/database_configurations_test.rb index 21b15b33..dcc7554a 100644 --- a/test/unit/database_configurations_test.rb +++ b/test/unit/database_configurations_test.rb @@ -222,7 +222,7 @@ def assert_all_tenants_found end end - with_scenario(:primary_db, :primary_record) do + with_scenario("sqlite/primary_db", :primary_record) do test "handles non-alphanumeric characters" do assert_empty(base_config.tenants) @@ -252,7 +252,7 @@ def assert_all_tenants_found end describe "implicit file creation" do - with_scenario(:primary_db, :primary_record) do + with_scenario("sqlite/primary_db", :primary_record) do # This is probably not behavior we want, long-term. See notes about the sqlite3 adapter in # tenant.rb. This test is descriptive, not prescriptive. test "creates a file if one does not exist" do diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index d77fd243..c5c42a4d 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -613,21 +613,6 @@ assert_not(TenantedApplicationRecord.tenant_exist?("doesnotexist")) end - test "it returns false if the tenant database is in the process of being migrated" do - # TODO: this test is SQLite-specific because it's using the Ready mutex directly. - config = TenantedApplicationRecord.tenanted_root_config - db_path = config.config_adapter.path_for(config.database_for("foo")) - - assert_not(TenantedApplicationRecord.tenant_exist?("foo")) - - ActiveRecord::Tenanted::Mutex::Ready.lock(db_path) do - assert_not(TenantedApplicationRecord.tenant_exist?("foo")) - FileUtils.touch(db_path) # pretend the database was created and migrated - end - - assert(TenantedApplicationRecord.tenant_exist?("foo")) - end - test "it returns true if the tenant database has been created" do TenantedApplicationRecord.create_tenant("foo") @@ -646,6 +631,22 @@ assert(TenantedApplicationRecord.tenant_exist?(12345678)) end end + + with_scenario("sqlite/primary_db", :primary_record) do + test "it returns false if the tenant database is in the process of being migrated" do + config = TenantedApplicationRecord.tenanted_root_config + db_path = config.config_adapter.path_for(config.database_for("foo")) + + assert_not(TenantedApplicationRecord.tenant_exist?("foo")) + + ActiveRecord::Tenanted::Mutex::Ready.lock(db_path) do + assert_not(TenantedApplicationRecord.tenant_exist?("foo")) + FileUtils.touch(db_path) # pretend the database was created and migrated + end + + assert(TenantedApplicationRecord.tenant_exist?("foo")) + end + end end describe ".create_tenant" do @@ -901,9 +902,10 @@ assert_equal([ "bar" ], TenantedApplicationRecord.tenants) end + end + for_each_scenario(only: { adapter: :sqlite }) do test "it does not return tenants that are not ready" do - # TODO: this test is SQLite-specific because it's using the Ready mutex directly. config = TenantedApplicationRecord.tenanted_root_config db_path = config.config_adapter.path_for(config.database_for("foo")) From ea0e14d8a26e87dfb0b96915160b2d5a70b94f9d Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 29 Oct 2025 10:20:48 -0400 Subject: [PATCH 02/49] Add mysql and a devcontainer to easily run it --- .devcontainer/Dockerfile | 6 ++++ .devcontainer/devcontainer.json | 20 ++++++++++++ .devcontainer/docker-compose.yml | 41 ++++++++++++++++++++++++ .devcontainer/scripts/post-create.sh | 26 +++++++++++++++ .devcontainer/scripts/provision-mysql.sh | 30 +++++++++++++++++ .devcontainer/scripts/wait-for-mysql.sh | 26 +++++++++++++++ Gemfile | 1 + Gemfile.lock | 3 ++ 8 files changed, 153 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100755 .devcontainer/scripts/post-create.sh create mode 100755 .devcontainer/scripts/provision-mysql.sh create mode 100755 .devcontainer/scripts/wait-for-mysql.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..ed125665 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,6 @@ +FROM ghcr.io/rails/devcontainer/images/ruby:3.3.5 + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends libmariadb-dev-compat \ + && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..87e4ad65 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,20 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/ruby +{ + "name": "activerecord-tenanted", + "dockerComposeFile": [ + "docker-compose.yml" + ], + "service": "app", + "workspaceFolder": "/workspaces/activerecord-tenanted", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker": { + "moby": false + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + }, + "ghcr.io/rails/devcontainer/features/mysql-client": {} + }, + "postCreateCommand": ".devcontainer/scripts/post-create.sh" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..652ebef6 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.9" + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + command: sleep infinity + volumes: + - ..:/workspaces/activerecord-tenanted:cached + environment: + MYSQL_HOST: mysql + MYSQL_PORT: "3306" + MYSQL_USERNAME: rails + MYSQL_PASSWORD: rails + MYSQL_ROOT_PASSWORD: devcontainer + MYSQL_DEVELOPMENT_DB: activerecord_tenanted_development + MYSQL_TEST_DB: activerecord_tenanted_test + MYSQL_INIT_TIMEOUT: "120" + depends_on: + mysql: + condition: service_healthy + + mysql: + image: mysql:8.0 + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: devcontainer + MYSQL_USER: rails + MYSQL_PASSWORD: rails + MYSQL_DATABASE: activerecord_tenanted_development + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 10s + retries: 10 + volumes: + - mysql-data:/var/lib/mysql + +volumes: + mysql-data: diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh new file mode 100755 index 00000000..1ead777d --- /dev/null +++ b/.devcontainer/scripts/post-create.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +echo "[devcontainer] Waiting for MySQL to become ready..." +"${SCRIPT_DIR}/wait-for-mysql.sh" + +echo "[devcontainer] Provisioning development and test databases..." +"${SCRIPT_DIR}/provision-mysql.sh" + +cd "${REPO_ROOT}" + +echo "[devcontainer] Installing bundle dependencies (if needed)..." +bundle check || bundle install + +cat <<'MSG' + +Devcontainer is ready. + +Next steps inside the container: + * bin/test-unit # baseline sqlite run + * bin/test-integration # integration suite + * mysql --host mysql --user rails --password=rails --skip-ssl --execute "SHOW DATABASES LIKE 'activerecord_tenanted_%';" +MSG diff --git a/.devcontainer/scripts/provision-mysql.sh b/.devcontainer/scripts/provision-mysql.sh new file mode 100755 index 00000000..5e71cff2 --- /dev/null +++ b/.devcontainer/scripts/provision-mysql.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +MYSQL_HOST=${MYSQL_HOST:-mysql} +MYSQL_PORT=${MYSQL_PORT:-3306} +MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-devcontainer} +MYSQL_USERNAME=${MYSQL_USERNAME:-rails} +MYSQL_PASSWORD=${MYSQL_PASSWORD:-rails} +MYSQL_DEVELOPMENT_DB=${MYSQL_DEVELOPMENT_DB:-activerecord_tenanted_development} +MYSQL_TEST_DB=${MYSQL_TEST_DB:-activerecord_tenanted_test} + +SQL=$(cat <&2 +exit 1 diff --git a/Gemfile b/Gemfile index 90435e88..55441bed 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ group :development, :test do gem "sqlite3", "2.7.4" gem "debug", "1.11.0" gem "minitest-parallel_fork", "2.1.0", require: false + gem "mysql2", "~> 0.5", require: false end group :rubocop do diff --git a/Gemfile.lock b/Gemfile.lock index 23dcda6d..c554c492 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -177,6 +177,8 @@ GEM minitest (5.26.0) minitest-parallel_fork (2.1.0) minitest (>= 5.15.0) + mysql2 (0.5.7) + bigdecimal net-imap (0.5.12) date net-protocol @@ -349,6 +351,7 @@ DEPENDENCIES importmap-rails jbuilder minitest-parallel_fork (= 2.1.0) + mysql2 (~> 0.5) propshaft puma (>= 5.0) rails! From 846629821a51664ecee483fd9842ab65735e7f0f Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 29 Oct 2025 10:21:31 -0400 Subject: [PATCH 03/49] Add mysql adapter with tests --- lib/active_record/tenanted.rb | 1 + .../tenanted/database_adapter.rb | 2 + .../tenanted/database_adapters/mysql.rb | 161 ++++++++++++++++++ .../database_configurations/base_config.rb | 16 +- lib/active_record/tenanted/database_tasks.rb | 13 +- .../mysql/20250213005959_add_age_to_users.rb | 5 + .../mysql/20250830152220_create_posts.rb | 10 ++ ...0250830170325_add_announcement_to_users.rb | 5 + ...0250830175957_add_announceable_to_users.rb | 5 + test/scenarios/mysql/primary_db/database.yml | 21 +++ .../mysql/primary_db/primary_record.rb | 22 +++ .../mysql/primary_db/secondary_record.rb | 22 +++ .../20250203191207_create_announcements.rb | 11 ++ .../mysql/primary_db/subtenant_record.rb | 24 +++ .../20250203191115_create_users.rb | 11 ++ test/scenarios/mysql/schema.rb | 19 +++ 16 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 lib/active_record/tenanted/database_adapters/mysql.rb create mode 100644 test/scenarios/mysql/20250213005959_add_age_to_users.rb create mode 100644 test/scenarios/mysql/20250830152220_create_posts.rb create mode 100644 test/scenarios/mysql/20250830170325_add_announcement_to_users.rb create mode 100644 test/scenarios/mysql/20250830175957_add_announceable_to_users.rb create mode 100644 test/scenarios/mysql/primary_db/database.yml create mode 100644 test/scenarios/mysql/primary_db/primary_record.rb create mode 100644 test/scenarios/mysql/primary_db/secondary_record.rb create mode 100644 test/scenarios/mysql/primary_db/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/mysql/primary_db/subtenant_record.rb create mode 100644 test/scenarios/mysql/primary_db/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/scenarios/mysql/schema.rb diff --git a/lib/active_record/tenanted.rb b/lib/active_record/tenanted.rb index 1b0b62b1..b5d3bef9 100644 --- a/lib/active_record/tenanted.rb +++ b/lib/active_record/tenanted.rb @@ -6,6 +6,7 @@ loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord) loader.inflector.inflect( "sqlite" => "SQLite", + "mysql" => "MySQL", ) loader.setup diff --git a/lib/active_record/tenanted/database_adapter.rb b/lib/active_record/tenanted/database_adapter.rb index b773941b..99c540ec 100644 --- a/lib/active_record/tenanted/database_adapter.rb +++ b/lib/active_record/tenanted/database_adapter.rb @@ -25,6 +25,8 @@ def new(db_config) end register "sqlite3", "ActiveRecord::Tenanted::DatabaseAdapters::SQLite" + register "trilogy", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" + register "mysql2", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" end end end diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb new file mode 100644 index 00000000..19c845bc --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters + class MySQL + attr_reader :db_config + + def initialize(db_config) + @db_config = db_config + end + + def tenant_databases + like_pattern = db_config.database.gsub(/%\{tenant\}/, "%") + scanner = Regexp.new("^" + Regexp.escape(db_config.database).gsub(Regexp.escape("%{tenant}"), "(.+)") + "$") + + server_config = db_config.configuration_hash.dup + server_config.delete(:database) + temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "#{db_config.name}_server", + server_config + ) + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| + result = conn.execute("SHOW DATABASES LIKE '#{like_pattern}'") + + result.filter_map do |row| + db_name = row[0] || row.first + match = db_name.match(scanner) + if match.nil? + Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from database #{db_name.inspect}" + nil + else + match[1] + end + end + end + rescue ActiveRecord::NoDatabaseError, Mysql2::Error => e + Rails.logger.warn "Could not list tenant databases: #{e.message}" + [] + end + + def validate_tenant_name(tenant_name) + tenant_name_str = tenant_name.to_s + + database_name = sprintf(db_config.database, tenant: tenant_name_str) + + return if database_name.include?("%{") || database_name.include?("%}") + + if database_name.length > 64 + raise ActiveRecord::Tenanted::BadTenantNameError, "Database name too long (max 64 characters): #{database_name.inspect}" + end + + if database_name.match?(/[^a-zA-Z0-9_$-]/) + raise ActiveRecord::Tenanted::BadTenantNameError, "Database name contains invalid characters (only letters, numbers, underscore, $ and hyphen allowed): #{database_name.inspect}" + end + + if database_name.match?(/^\d/) + raise ActiveRecord::Tenanted::BadTenantNameError, "Database name cannot start with a number: #{database_name.inspect}" + end + + reserved_words = %w[ + database databases table tables column columns index indexes + select insert update delete create drop alter + user users group groups order by from where + and or not null true false + ] + + if reserved_words.include?(database_name.downcase) + raise ActiveRecord::Tenanted::BadTenantNameError, "Database name is a reserved MySQL keyword: #{database_name.inspect}" + end + end + + def create_database + # Create a temporary config without the specific database to connect to MySQL server + server_config = db_config.configuration_hash.dup + server_config.delete(:database) + temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "#{db_config.name}_server", + server_config + ) + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| + # Use ActiveRecord's built-in create_database method with charset/collation from config + create_options = {} + + # Add charset/encoding if specified + if charset = db_config.configuration_hash[:encoding] || db_config.configuration_hash[:charset] + create_options[:charset] = charset + end + + # Add collation if specified + if collation = db_config.configuration_hash[:collation] + create_options[:collation] = collation + end + + conn.create_database(database_path, create_options) + end + end + + def drop_database + # Create a temporary config without the specific database to connect to MySQL server + server_config = db_config.configuration_hash.dup + server_config.delete(:database) + temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "#{db_config.name}_server", + server_config + ) + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| + # Use ActiveRecord's built-in drop_database method + conn.drop_database(database_path) + end + end + + def database_exist? + server_config = db_config.configuration_hash.dup + server_config.delete(:database) + temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "#{db_config.name}_server", + server_config + ) + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| + result = conn.execute("SHOW DATABASES LIKE '#{database_path}'") + result.any? + end + rescue ActiveRecord::NoDatabaseError, Mysql2::Error + false + end + + def database_ready? + database_exist? + end + + def acquire_ready_lock(&block) + yield + end + + def ensure_database_directory_exists + database_path.present? + end + + def database_path + db_config.database + end + + def test_workerize(db, test_worker_id) + # TODO: Implement + end + + def path_for(database) + database + end + end + end + end +end diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 51e37bbe..028e5834 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -36,8 +36,19 @@ def database_for(tenant_name) db end + def host_for(tenant_name) + return nil unless host&.include?("%{tenant}") + sprintf(host, tenant: tenant_name) + end + def tenants - config_adapter.tenant_databases + all_databases = ActiveRecord::Base.configurations.configs_for(env_name: env_name) + non_tenant_db_names = all_databases.reject { |c| c.configuration_hash[:tenanted] }.map(&:database).compact + + config_adapter.tenant_databases.reject do |tenant_name| + tenant_db_name = database_for(tenant_name) + non_tenant_db_names.include?(tenant_db_name) + end end def new_tenant_config(tenant_name) @@ -46,6 +57,9 @@ def new_tenant_config(tenant_name) hash[:tenant] = tenant_name hash[:database] = database_for(tenant_name) hash[:tenanted_config_name] = name + # Only override host if it contains a tenant template + new_host = host_for(tenant_name) + hash[:host] = new_host if new_host end Tenanted::DatabaseConfigurations::TenantConfig.new(env_name, config_name, config_hash) end diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index 55201b14..a34c320e 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -81,16 +81,25 @@ def set_current_tenant # This is essentially a simplified implementation of ActiveRecord::Tasks::DatabaseTasks.migrate def migrate(config) + unless config.config_adapter.database_exist? + config.config_adapter.create_database + $stdout.puts "Created database '#{config.database}'" if verbose? + end + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(config) do |conn| pool = conn.pool + begin + database_already_initialized = pool.schema_migration.table_exists? + rescue ActiveRecord::NoDatabaseError + end + # initialize_database - unless pool.schema_migration.table_exists? + unless database_already_initialized schema_dump_path = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(config) if schema_dump_path && File.exist?(schema_dump_path) ActiveRecord::Tasks::DatabaseTasks.load_schema(config) end - # TODO: emit a "Created database" message once we sort out implicit creation end # migrate diff --git a/test/scenarios/mysql/20250213005959_add_age_to_users.rb b/test/scenarios/mysql/20250213005959_add_age_to_users.rb new file mode 100644 index 00000000..bfe140c0 --- /dev/null +++ b/test/scenarios/mysql/20250213005959_add_age_to_users.rb @@ -0,0 +1,5 @@ +class AddAgeToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :age, :integer + end +end diff --git a/test/scenarios/mysql/20250830152220_create_posts.rb b/test/scenarios/mysql/20250830152220_create_posts.rb new file mode 100644 index 00000000..7242786a --- /dev/null +++ b/test/scenarios/mysql/20250830152220_create_posts.rb @@ -0,0 +1,10 @@ +class CreatePosts < ActiveRecord::Migration[8.1] + def change + create_table :posts do |t| + t.string :title + t.references :user, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/test/scenarios/mysql/20250830170325_add_announcement_to_users.rb b/test/scenarios/mysql/20250830170325_add_announcement_to_users.rb new file mode 100644 index 00000000..73353f9a --- /dev/null +++ b/test/scenarios/mysql/20250830170325_add_announcement_to_users.rb @@ -0,0 +1,5 @@ +class AddAnnouncementToUsers < ActiveRecord::Migration[8.1] + def change + add_reference :users, :announcement, null: true + end +end diff --git a/test/scenarios/mysql/20250830175957_add_announceable_to_users.rb b/test/scenarios/mysql/20250830175957_add_announceable_to_users.rb new file mode 100644 index 00000000..70da8289 --- /dev/null +++ b/test/scenarios/mysql/20250830175957_add_announceable_to_users.rb @@ -0,0 +1,5 @@ +class AddAnnounceableToUsers < ActiveRecord::Migration[8.1] + def change + add_reference :users, :announceable, polymorphic: true, null: true + end +end diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml new file mode 100644 index 00000000..2f3e776b --- /dev/null +++ b/test/scenarios/mysql/primary_db/database.yml @@ -0,0 +1,21 @@ +default: &default + adapter: mysql2 + encoding: utf8mb4 + max_connections: 5 + username: root + password: devcontainer + host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> + port: 3306 + sslmode: disabled + +test: + primary: + <<: *default + tenanted: true + database: active_record_tenanted-%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + shared: + <<: *default + database: active_record_tenanted-shared + migrations_paths: "%{db_path}/shared_migrations" + diff --git a/test/scenarios/mysql/primary_db/primary_record.rb b/test/scenarios/mysql/primary_db/primary_record.rb new file mode 100644 index 00000000..f9ad6c58 --- /dev/null +++ b/test/scenarios/mysql/primary_db/primary_record.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + primary_abstract_class + + tenanted +end + +class User < TenantedApplicationRecord +end + +class Post < TenantedApplicationRecord +end + +class SharedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: { writing: :shared } +end + +class Announcement < SharedApplicationRecord +end diff --git a/test/scenarios/mysql/primary_db/secondary_record.rb b/test/scenarios/mysql/primary_db/secondary_record.rb new file mode 100644 index 00000000..7fdd930a --- /dev/null +++ b/test/scenarios/mysql/primary_db/secondary_record.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + tenanted +end + +class User < TenantedApplicationRecord +end + +class Post < TenantedApplicationRecord +end + +class SharedApplicationRecord < ActiveRecord::Base + primary_abstract_class + + connects_to database: { writing: :shared } +end + +class Announcement < SharedApplicationRecord +end diff --git a/test/scenarios/mysql/primary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/mysql/primary_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/mysql/primary_db/shared_migrations/20250203191207_create_announcements.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateAnnouncements < ActiveRecord::Migration[8.1] + def change + create_table :announcements do |t| + t.string :message + + t.timestamps + end + end +end diff --git a/test/scenarios/mysql/primary_db/subtenant_record.rb b/test/scenarios/mysql/primary_db/subtenant_record.rb new file mode 100644 index 00000000..5f3a22ba --- /dev/null +++ b/test/scenarios/mysql/primary_db/subtenant_record.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + primary_abstract_class + + tenanted +end + +class User < ActiveRecord::Base + subtenant_of "TenantedApplicationRecord" +end + +class Post < ActiveRecord::Base + subtenant_of "TenantedApplicationRecord" +end + +class SharedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: { writing: :shared } +end + +class Announcement < SharedApplicationRecord +end diff --git a/test/scenarios/mysql/primary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/mysql/primary_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/mysql/primary_db/tenanted_migrations/20250203191115_create_users.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email + + t.timestamps + end + end +end diff --git a/test/scenarios/mysql/schema.rb b/test/scenarios/mysql/schema.rb new file mode 100644 index 00000000..74d3e220 --- /dev/null +++ b/test/scenarios/mysql/schema.rb @@ -0,0 +1,19 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2025_02_03_191115) do + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email" + t.datetime "updated_at", null: false + end +end From d975bd48a188d52686123bebb8991ab1b853e4e3 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 29 Oct 2025 12:15:34 -0400 Subject: [PATCH 04/49] Simplify devcontainer setup --- .devcontainer/devcontainer.json | 3 +-- .devcontainer/scripts/post-create.sh | 26 -------------------- .devcontainer/scripts/provision-mysql.sh | 30 ------------------------ .devcontainer/scripts/wait-for-mysql.sh | 26 -------------------- 4 files changed, 1 insertion(+), 84 deletions(-) delete mode 100755 .devcontainer/scripts/post-create.sh delete mode 100755 .devcontainer/scripts/provision-mysql.sh delete mode 100755 .devcontainer/scripts/wait-for-mysql.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 87e4ad65..e545495a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,6 +15,5 @@ "version": "latest" }, "ghcr.io/rails/devcontainer/features/mysql-client": {} - }, - "postCreateCommand": ".devcontainer/scripts/post-create.sh" + } } diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh deleted file mode 100755 index 1ead777d..00000000 --- a/.devcontainer/scripts/post-create.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" - -echo "[devcontainer] Waiting for MySQL to become ready..." -"${SCRIPT_DIR}/wait-for-mysql.sh" - -echo "[devcontainer] Provisioning development and test databases..." -"${SCRIPT_DIR}/provision-mysql.sh" - -cd "${REPO_ROOT}" - -echo "[devcontainer] Installing bundle dependencies (if needed)..." -bundle check || bundle install - -cat <<'MSG' - -Devcontainer is ready. - -Next steps inside the container: - * bin/test-unit # baseline sqlite run - * bin/test-integration # integration suite - * mysql --host mysql --user rails --password=rails --skip-ssl --execute "SHOW DATABASES LIKE 'activerecord_tenanted_%';" -MSG diff --git a/.devcontainer/scripts/provision-mysql.sh b/.devcontainer/scripts/provision-mysql.sh deleted file mode 100755 index 5e71cff2..00000000 --- a/.devcontainer/scripts/provision-mysql.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -MYSQL_HOST=${MYSQL_HOST:-mysql} -MYSQL_PORT=${MYSQL_PORT:-3306} -MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-devcontainer} -MYSQL_USERNAME=${MYSQL_USERNAME:-rails} -MYSQL_PASSWORD=${MYSQL_PASSWORD:-rails} -MYSQL_DEVELOPMENT_DB=${MYSQL_DEVELOPMENT_DB:-activerecord_tenanted_development} -MYSQL_TEST_DB=${MYSQL_TEST_DB:-activerecord_tenanted_test} - -SQL=$(cat <&2 -exit 1 From bbd4e53e0e544455c86d5bb2ed0dd1524eb09747 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 29 Oct 2025 15:28:37 -0400 Subject: [PATCH 05/49] Clean up the mysql adapter --- .../tenanted/database_adapters/mysql.rb | 82 +++++++------------ 1 file changed, 28 insertions(+), 54 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index 19c845bc..0bea816f 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -14,16 +14,8 @@ def tenant_databases like_pattern = db_config.database.gsub(/%\{tenant\}/, "%") scanner = Regexp.new("^" + Regexp.escape(db_config.database).gsub(Regexp.escape("%{tenant}"), "(.+)") + "$") - server_config = db_config.configuration_hash.dup - server_config.delete(:database) - temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( - db_config.env_name, - "#{db_config.name}_server", - server_config - ) - - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| - result = conn.execute("SHOW DATABASES LIKE '#{like_pattern}'") + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + result = connection.execute("SHOW DATABASES LIKE '#{like_pattern}'") result.filter_map do |row| db_name = row[0] || row.first @@ -73,59 +65,25 @@ def validate_tenant_name(tenant_name) end def create_database - # Create a temporary config without the specific database to connect to MySQL server - server_config = db_config.configuration_hash.dup - server_config.delete(:database) - temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( - db_config.env_name, - "#{db_config.name}_server", - server_config - ) - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| - # Use ActiveRecord's built-in create_database method with charset/collation from config - create_options = {} - - # Add charset/encoding if specified - if charset = db_config.configuration_hash[:encoding] || db_config.configuration_hash[:charset] - create_options[:charset] = charset - end - - # Add collation if specified - if collation = db_config.configuration_hash[:collation] - create_options[:collation] = collation + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + create_options = Hash.new.tap do |options| + options[:charset] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.include?(:encoding) + options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.include?(:collation) end - conn.create_database(database_path, create_options) + connection.create_database(database_path, create_options) end end def drop_database - # Create a temporary config without the specific database to connect to MySQL server - server_config = db_config.configuration_hash.dup - server_config.delete(:database) - temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( - db_config.env_name, - "#{db_config.name}_server", - server_config - ) - - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| - # Use ActiveRecord's built-in drop_database method - conn.drop_database(database_path) + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + connection.drop_database(database_path) end end def database_exist? - server_config = db_config.configuration_hash.dup - server_config.delete(:database) - temp_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( - db_config.env_name, - "#{db_config.name}_server", - server_config - ) - - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(temp_config) do |conn| - result = conn.execute("SHOW DATABASES LIKE '#{database_path}'") + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + result = connection.execute("SHOW DATABASES LIKE '#{database_path}'") result.any? end rescue ActiveRecord::NoDatabaseError, Mysql2::Error @@ -149,12 +107,28 @@ def database_path end def test_workerize(db, test_worker_id) - # TODO: Implement + test_worker_suffix = "_#{test_worker_id}" + + if db.end_with?(test_worker_suffix) + db + else + db + test_worker_suffix + end end def path_for(database) database end + + private + def configuration_hash_without_database + configuration_hash = db_config.configuration_hash.dup.merge(database: nil) + ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + db_config.name.to_s, + configuration_hash + ) + end end end end From 444532939139f8b3cd105b410c3ff7e105c12364 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 30 Oct 2025 11:41:50 -0400 Subject: [PATCH 06/49] Refactor --- lib/active_record/tenanted/database_adapters/mysql.rb | 8 +++----- .../tenanted/database_configurations/base_config.rb | 6 +++--- test/scenarios/mysql/primary_db/database.yml | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index 0bea816f..dcf34ee2 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -34,9 +34,7 @@ def tenant_databases end def validate_tenant_name(tenant_name) - tenant_name_str = tenant_name.to_s - - database_name = sprintf(db_config.database, tenant: tenant_name_str) + database_name = sprintf(db_config.database, tenant: tenant_name.to_s) return if database_name.include?("%{") || database_name.include?("%}") @@ -67,8 +65,8 @@ def validate_tenant_name(tenant_name) def create_database ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| create_options = Hash.new.tap do |options| - options[:charset] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.include?(:encoding) - options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.include?(:collation) + options[:charset] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.include?(:encoding) + options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.include?(:collation) end connection.create_database(database_path, create_options) diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 028e5834..5790aff1 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -43,11 +43,11 @@ def host_for(tenant_name) def tenants all_databases = ActiveRecord::Base.configurations.configs_for(env_name: env_name) - non_tenant_db_names = all_databases.reject { |c| c.configuration_hash[:tenanted] }.map(&:database).compact + untenanted = all_databases.reject { |c| c.configuration_hash[:tenanted] }.filter_map(&:database) config_adapter.tenant_databases.reject do |tenant_name| - tenant_db_name = database_for(tenant_name) - non_tenant_db_names.include?(tenant_db_name) + database = database_for(tenant_name) + untenanted.include?(database) end end diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index 2f3e776b..aea6976c 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -16,6 +16,6 @@ test: migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default - database: active_record_tenanted-shared + database: shared migrations_paths: "%{db_path}/shared_migrations" From 1b87d517b89bfddd62bb228d97456408a8e737b1 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 30 Oct 2025 13:41:10 -0400 Subject: [PATCH 07/49] Update integration script --- bin/test-integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/test-integration b/bin/test-integration index f3d896a0..1e6e8f18 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -20,7 +20,7 @@ require "open3" require "active_support" require "active_support/core_ext/object" # deep_dup -scenarios = Dir.glob("test/scenarios/*db/*record.rb").map do |path| +scenarios = Dir.glob("test/scenarios/sqlite/*db/*record.rb").map do |path| database, models = path.scan(%r{test/scenarios/(.+db)/(.+record).rb}).flatten { database: database, models: models } end From 1c6eda242086b34f6c1422f59961dbf231d66db9 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 30 Oct 2025 13:51:54 -0400 Subject: [PATCH 08/49] Add adapter folder and get sqlite integration tests to pass --- bin/test-integration | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 1e6e8f18..5dbd5735 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -17,12 +17,13 @@ require "fileutils" require "tmpdir" require "yaml" require "open3" +require "debug" require "active_support" require "active_support/core_ext/object" # deep_dup scenarios = Dir.glob("test/scenarios/sqlite/*db/*record.rb").map do |path| - database, models = path.scan(%r{test/scenarios/(.+db)/(.+record).rb}).flatten - { database: database, models: models } + adapter, database, models = path.scan(%r{test/scenarios/(.+)/(.+db)/(.+record).rb}).flatten + { adapter: adapter, database: database, models: models } end COLOR_FG_BLUE = "\e[0;34m" @@ -53,7 +54,7 @@ def run_scenario(scenario) FileUtils.copy_entry(SMARTY_PATH, app_path) # generate database file - database_file = File.join(GEM_PATH, "test/scenarios/#{scenario[:database]}/database.yml") + database_file = File.join(GEM_PATH, "test/scenarios/#{scenario[:adapter]}/#{scenario[:database]}/database.yml") database_file_contents = sprintf(File.read(database_file), storage: "storage", db_path: "db") database_file_hash = YAML.load(database_file_contents) database_file_hash["development"] = database_file_hash["test"].deep_dup @@ -63,14 +64,14 @@ def run_scenario(scenario) File.write(File.join(app_path, "config/database.yml"), database_file_hash.to_yaml) # generate models using ApplicationRecord - models_file = File.join(GEM_PATH, "test/scenarios/#{scenario[:database]}/#{scenario[:models]}.rb") + models_file = File.join(GEM_PATH, "test/scenarios/#{scenario[:adapter]}/#{scenario[:database]}/#{scenario[:models]}.rb") models_file_contents = File.read(models_file) .gsub("TenantedApplicationRecord", "ApplicationRecord") File.write(File.join(app_path, "app/models/application_record.rb"), models_file_contents) # copy migrations from scenario and smarty app FileUtils.mkdir_p(File.join(app_path, "db")) - FileUtils.cp_r(Dir.glob(File.join(GEM_PATH, "test/scenarios/#{scenario[:database]}/*migrations")), + FileUtils.cp_r(Dir.glob(File.join(GEM_PATH, "test/scenarios/#{scenario[:adapter]}/#{scenario[:database]}/*migrations")), File.join(app_path, "db")) FileUtils.cp_r(Dir.glob(File.join(app_path, "db/migrate/*rb")), File.join(app_path, "db/tenanted_migrations")) From 94e6d868716f5f18c57e5ef9292ca53f541b7a7e Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 30 Oct 2025 14:28:27 -0400 Subject: [PATCH 09/49] Add chrome to devcontainer so that we can run integration tests in it --- .devcontainer/Dockerfile | 11 ++++++++++- test/smarty/test/application_system_test_case.rb | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ed125665..20d35f4d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,5 +2,14 @@ FROM ghcr.io/rails/devcontainer/images/ruby:3.3.5 USER root RUN apt-get update \ - && apt-get install -y --no-install-recommends libmariadb-dev-compat \ + && apt-get install -y --no-install-recommends \ + libmariadb-dev-compat \ + chromium \ + chromium-driver \ + libnss3 \ + libfontconfig1 \ && rm -rf /var/lib/apt/lists/* + +ENV CHROME_BIN=/usr/bin/chromium +ENV CHROMEDRIVER_PATH=/usr/bin/chromedriver +ENV SE_AVOID_STATS=true diff --git a/test/smarty/test/application_system_test_case.rb b/test/smarty/test/application_system_test_case.rb index 92a0bed6..d351821f 100644 --- a/test/smarty/test/application_system_test_case.rb +++ b/test/smarty/test/application_system_test_case.rb @@ -1,7 +1,19 @@ require "test_helper" -Capybara.server = :puma, { Silent: true } # suppress server boot announcement +Capybara.server = :puma, { Silent: true } class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + if ENV["CHROMEDRIVER_PATH"] + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] do |driver_option| + driver_option.add_argument("--disable-dev-shm-usage") + driver_option.add_argument("--no-sandbox") + end + + Selenium::WebDriver::Chrome::Service.driver_path = ENV["CHROMEDRIVER_PATH"] + else + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] do |driver_option| + driver_option.add_argument("--disable-dev-shm-usage") + driver_option.add_argument("--no-sandbox") + end + end end From 132e9c6f88701d9bb40c75a2cbbd12eedc8c40d2 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 30 Oct 2025 15:17:30 -0400 Subject: [PATCH 10/49] A couple fixes. Switch to mysql --- bin/test-integration | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 5dbd5735..a81efc95 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -17,11 +17,12 @@ require "fileutils" require "tmpdir" require "yaml" require "open3" +require "erb" require "debug" require "active_support" require "active_support/core_ext/object" # deep_dup -scenarios = Dir.glob("test/scenarios/sqlite/*db/*record.rb").map do |path| +scenarios = Dir.glob("test/scenarios/mysql/*db/*record.rb").map do |path| adapter, database, models = path.scan(%r{test/scenarios/(.+)/(.+db)/(.+record).rb}).flatten { adapter: adapter, database: database, models: models } end @@ -55,8 +56,8 @@ def run_scenario(scenario) # generate database file database_file = File.join(GEM_PATH, "test/scenarios/#{scenario[:adapter]}/#{scenario[:database]}/database.yml") - database_file_contents = sprintf(File.read(database_file), storage: "storage", db_path: "db") - database_file_hash = YAML.load(database_file_contents) + database_file_contents = ERB.new(File.read(database_file)).result + database_file_hash = YAML.unsafe_load(database_file_contents) database_file_hash["development"] = database_file_hash["test"].deep_dup database_file_hash["development"].each_value do |hash| hash["database"] = hash["database"].sub("/test/", "/development/") From 9da4474fced9b50f0e2943e8816dc4ee862cc781 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Mon, 3 Nov 2025 10:54:42 -0500 Subject: [PATCH 11/49] Fix database tasks --- .../database_configurations/tenant_config.rb | 14 ++++++++++++++ lib/active_record/tenanted/database_tasks.rb | 8 ++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/active_record/tenanted/database_configurations/tenant_config.rb b/lib/active_record/tenanted/database_configurations/tenant_config.rb index 93a4f835..d1129b7b 100644 --- a/lib/active_record/tenanted/database_configurations/tenant_config.rb +++ b/lib/active_record/tenanted/database_configurations/tenant_config.rb @@ -13,6 +13,20 @@ def tenant configuration_hash.fetch(:tenant) end + def database + configuration_hash.fetch(:database) + end + + def configuration_hash + hash = super + if hash[:database]&.include?("%{tenant}") + hash = hash.dup + tenant_name = hash.fetch(:tenant) + hash[:database] = sprintf(hash[:database], tenant: tenant_name) + end + hash + end + def config_adapter @config_adapter ||= ActiveRecord::Tenanted::DatabaseAdapter.new(self) end diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index a34c320e..3799de80 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -92,9 +92,9 @@ def migrate(config) begin database_already_initialized = pool.schema_migration.table_exists? rescue ActiveRecord::NoDatabaseError + database_already_initialized = false end - # initialize_database unless database_already_initialized schema_dump_path = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(config) if schema_dump_path && File.exist?(schema_dump_path) @@ -102,17 +102,17 @@ def migrate(config) end end - # migrate migrated = false + if pool.migration_context.pending_migration_versions.present? pool.migration_context.migrate(nil) pool.schema_cache.clear! migrated = true end - # dump the schema and schema cache if Rails.env.development? || ENV["ARTENANT_SCHEMA_DUMP"].present? - if migrated + should_dump_schema = migrated || !database_already_initialized + if should_dump_schema ActiveRecord::Tasks::DatabaseTasks.dump_schema(config) end From ef0819d57ca642c1b95fef67f3191c3bd08d5942 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Mon, 3 Nov 2025 11:09:26 -0500 Subject: [PATCH 12/49] Fix tests for sqlite --- bin/test-integration | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/test-integration b/bin/test-integration index a81efc95..584fd2e2 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -56,7 +56,10 @@ def run_scenario(scenario) # generate database file database_file = File.join(GEM_PATH, "test/scenarios/#{scenario[:adapter]}/#{scenario[:database]}/database.yml") - database_file_contents = ERB.new(File.read(database_file)).result + erb_content = ERB.new(File.read(database_file)).result + storage_path = File.join(app_path, "storage") + db_path = File.join(app_path, "db") + database_file_contents = sprintf(erb_content, storage: storage_path, db_path: db_path) database_file_hash = YAML.unsafe_load(database_file_contents) database_file_hash["development"] = database_file_hash["test"].deep_dup database_file_hash["development"].each_value do |hash| From 5929062f3b14b9cd993bb8da230b207cb0844f1c Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Mon, 3 Nov 2025 11:23:20 -0500 Subject: [PATCH 13/49] Make fake database creation work for all adapters --- bin/test-integration | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 584fd2e2..793d3b42 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -96,10 +96,7 @@ def run_scenario(scenario) File.exist?("db/#{prefix}schema_cache.yml") || abort("Schema cache dump not generated") # create a fake tenant database to validate that it is deleted in the test suite - dev_db = Dir.glob("storage/development/*/development-tenant/main.sqlite3").first - test_db = dev_db.gsub("/development/", "/test/").gsub("development-tenant", "delete-me") - FileUtils.mkdir_p(File.dirname(test_db)) - FileUtils.touch(test_db) + run_cmd(scenario, %{bin/rails runner "ApplicationRecord.tenanted_root_config.new_tenant_config('delete-me').config_adapter.create_database"}, env: { "RAILS_ENV" => "test" }) # run in parallel half the time env = if rand(2).zero? From e980a15a91364f6de7351c6e5c922fd817a1021d Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Mon, 3 Nov 2025 11:45:30 -0500 Subject: [PATCH 14/49] shorten the db name so it's shorter than the mysql max (64) --- test/integration/test/active_job_test.rb | 4 ++-- test/scenarios/mysql/primary_db/database.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/test/active_job_test.rb b/test/integration/test/active_job_test.rb index 5e4bb38e..8a2ea58f 100644 --- a/test/integration/test/active_job_test.rb +++ b/test/integration/test/active_job_test.rb @@ -26,7 +26,7 @@ class NoteCheerioJobTest < ActiveJob::TestCase end test "global id locator catches wrong tenant context" do - tenant = __method__ + tenant = "global_id_locator_catches_wrong_tenant" note = ApplicationRecord.create_tenant(tenant) do Note.create!(title: "asdf", body: "Lorem ipsum.") end @@ -40,7 +40,7 @@ class NoteCheerioJobTest < ActiveJob::TestCase end test "global id locator catches untenanted context" do - tenant = __method__ + tenant = "global_id_locator_catches_untenanted_context" note = ApplicationRecord.create_tenant(tenant) do Note.create!(title: "asdf", body: "Lorem ipsum.") end diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index aea6976c..abcb464d 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -12,7 +12,7 @@ test: primary: <<: *default tenanted: true - database: active_record_tenanted-%%{tenant} + database: primary-%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default From 6c9121bab40b718537f05f4e2850839bb04b09c8 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Mon, 3 Nov 2025 13:57:42 -0500 Subject: [PATCH 15/49] Working(ish) integration tests with mysql --- bin/test-integration | 7 ++++--- lib/active_record/tenanted/database_tasks.rb | 3 ++- test/test_helper.rb | 2 -- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 793d3b42..779fb6d7 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -30,7 +30,7 @@ end COLOR_FG_BLUE = "\e[0;34m" COLOR_RESET = "\e[0m" -def run_cmd(scenario, cmd, verbose: false, env: {}) +def run_cmd(scenario, cmd, verbose: true, env: {}) message = "#{COLOR_FG_BLUE}#{scenario}#{COLOR_RESET} > #{env.presence} #{cmd}" print message + (PARALLEL ? "\n" : "") @@ -88,7 +88,8 @@ def run_scenario(scenario) run_cmd(scenario, "bundle check || bundle install") # set up the database and schema files - run_cmd(scenario, "bin/rails db:prepare") + mysql_env = ENV.select { |k, _| k.start_with?("MYSQL_") } + run_cmd(scenario, "bin/rails db:prepare", env: { "ARTENANT_SCHEMA_DUMP" => "1", "VERBOSE" => "true", **mysql_env }) # validate-ish the setup prefix = scenario[:database].match?(/\Asecondary/) ? "tenanted_" : "" @@ -96,7 +97,7 @@ def run_scenario(scenario) File.exist?("db/#{prefix}schema_cache.yml") || abort("Schema cache dump not generated") # create a fake tenant database to validate that it is deleted in the test suite - run_cmd(scenario, %{bin/rails runner "ApplicationRecord.tenanted_root_config.new_tenant_config('delete-me').config_adapter.create_database"}, env: { "RAILS_ENV" => "test" }) + run_cmd(scenario, %{bin/rails runner "ApplicationRecord.tenanted_root_config.new_tenant_config('delete-me').config_adapter.create_database"}, env: { "RAILS_ENV" => "test", **mysql_env }) # run in parallel half the time env = if rand(2).zero? diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index 3799de80..da3daaff 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -111,7 +111,8 @@ def migrate(config) end if Rails.env.development? || ENV["ARTENANT_SCHEMA_DUMP"].present? - should_dump_schema = migrated || !database_already_initialized + should_dump_schema = migrated || !database_already_initialized || (config.primary? && ENV["ARTENANT_SCHEMA_DUMP"].present?) + if should_dump_schema ActiveRecord::Tasks::DatabaseTasks.dump_schema(config) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 61c45b68..014f4333 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -300,8 +300,6 @@ def cleanup_shared_databases shared_configs.each do |config| if db_adapter == "mysql" ActiveRecord::Tasks::DatabaseTasks.drop(config) - ActiveRecord::Tasks::DatabaseTasks.create(config) - ActiveRecord::Tasks::DatabaseTasks.migrate(config) else pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool( config.name, From 1b147f08cabf706b9775430631a5e747b28fe551 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Tue, 4 Nov 2025 10:59:44 -0500 Subject: [PATCH 16/49] Specify RAILS_ENV --- bin/test-integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/test-integration b/bin/test-integration index 779fb6d7..59bb9f64 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -89,7 +89,7 @@ def run_scenario(scenario) # set up the database and schema files mysql_env = ENV.select { |k, _| k.start_with?("MYSQL_") } - run_cmd(scenario, "bin/rails db:prepare", env: { "ARTENANT_SCHEMA_DUMP" => "1", "VERBOSE" => "true", **mysql_env }) + run_cmd(scenario, "bin/rails db:prepare", env: { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "VERBOSE" => "true", **mysql_env }) # validate-ish the setup prefix = scenario[:database].match?(/\Asecondary/) ? "tenanted_" : "" From ce9f2885ce86c8f422cb0c2453e507d7d5ce76d8 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Tue, 4 Nov 2025 12:02:18 -0500 Subject: [PATCH 17/49] Refactor and try to make test_workerize work --- .devcontainer/docker-compose.yml | 2 - .../tenanted/database_adapters/mysql.rb | 55 +++++++++++++------ lib/active_record/tenanted/tenant.rb | 8 ++- test/scenarios/mysql/primary_db/database.yml | 3 +- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 652ebef6..f75d2c31 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: app: build: diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index dcf34ee2..659fead9 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -11,29 +11,42 @@ def initialize(db_config) end def tenant_databases - like_pattern = db_config.database.gsub(/%\{tenant\}/, "%") - scanner = Regexp.new("^" + Regexp.escape(db_config.database).gsub(Regexp.escape("%{tenant}"), "(.+)") + "$") - - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| - result = connection.execute("SHOW DATABASES LIKE '#{like_pattern}'") - - result.filter_map do |row| - db_name = row[0] || row.first - match = db_name.match(scanner) - if match.nil? - Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from database #{db_name.inspect}" - nil - else - match[1] + like_pattern = db_config.database_for("%") + scanner_pattern = db_config.database_for("(.+)") + scanner = Regexp.new("^" + Regexp.escape(scanner_pattern).gsub(Regexp.escape("(.+)"), "(.+)") + "$") + + begin + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + result = connection.execute("SHOW DATABASES LIKE '#{like_pattern}'") + + result.filter_map do |row| + db_name = row[0] || row.first + match = db_name.match(scanner) + if match.nil? + Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from database #{db_name.inspect}" + nil + else + tenant_name = match[1] + + # Strip test_worker_id suffix if present + if db_config.test_worker_id + test_worker_suffix = "_#{db_config.test_worker_id}" + tenant_name = tenant_name.delete_suffix(test_worker_suffix) + end + + tenant_name + end end end + rescue ActiveRecord::NoDatabaseError, Mysql2::Error => e + Rails.logger.warn "Could not list tenant databases: #{e.message}" + [] end - rescue ActiveRecord::NoDatabaseError, Mysql2::Error => e - Rails.logger.warn "Could not list tenant databases: #{e.message}" - [] end def validate_tenant_name(tenant_name) + return if tenant_name == "%" || tenant_name == "(.+)" + database_name = sprintf(db_config.database, tenant: tenant_name.to_s) return if database_name.include?("%{") || database_name.include?("%}") @@ -75,7 +88,13 @@ def create_database def drop_database ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| - connection.drop_database(database_path) + connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(database_path)}") + end + rescue Mysql2::Error => e + if e.message.include?("Can't drop database") || e.message.include?("doesn't exist") + Rails.logger.debug "Database #{database_path} doesn't exist or already dropped" + else + raise end end diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dc91b979..0208ce5d 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -158,6 +158,10 @@ def create_tenant(tenant_name, if_not_exists: false, &block) end def destroy_tenant(tenant_name) + adapter = tenanted_root_config.new_tenant_config(tenant_name).config_adapter + + return unless adapter.database_exist? + ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database" with_tenant(tenant_name, prohibit_shard_swapping: false) do @@ -166,7 +170,9 @@ def destroy_tenant(tenant_name) end end - tenanted_root_config.new_tenant_config(tenant_name).config_adapter.drop_database + adapter.drop_database + rescue => e + Rails.logger.warn "Failed to destroy tenant #{tenant_name}: #{e.message}" end def tenants diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index abcb464d..87380376 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -1,7 +1,8 @@ default: &default adapter: mysql2 encoding: utf8mb4 - max_connections: 5 + pool: 20 + max_connections: 20 username: root password: devcontainer host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> From 7b28b4b3fe96121e8783255aea055ba7e7cbf0ba Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Tue, 4 Nov 2025 12:09:00 -0500 Subject: [PATCH 18/49] Isolate the dbs for integration tests --- bin/test-integration | 8 ++++++-- test/scenarios/mysql/primary_db/database.yml | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 59bb9f64..235510be 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -51,6 +51,9 @@ def run_scenario(scenario) at_exit { FileUtils.remove_entry app_path } unless OPT_KEEP_DIR puts "Creating integration app for #{COLOR_FG_BLUE}#{scenario}#{COLOR_RESET} at #{app_path}" + # Generate a unique database prefix for this scenario to avoid conflicts + db_prefix = "test_#{scenario[:database]}_#{scenario[:models]}_#{Process.pid}".gsub(/[^a-zA-Z0-9_]/, "_") + # make a copy of the smarty app FileUtils.copy_entry(SMARTY_PATH, app_path) @@ -89,6 +92,7 @@ def run_scenario(scenario) # set up the database and schema files mysql_env = ENV.select { |k, _| k.start_with?("MYSQL_") } + mysql_env["MYSQL_DB_PREFIX"] = db_prefix run_cmd(scenario, "bin/rails db:prepare", env: { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "VERBOSE" => "true", **mysql_env }) # validate-ish the setup @@ -101,9 +105,9 @@ def run_scenario(scenario) # run in parallel half the time env = if rand(2).zero? - { "PARALLEL_WORKERS" => "2" } + { "PARALLEL_WORKERS" => "2", **mysql_env } else - { "PARALLEL_WORKERS" => "1" } + { "PARALLEL_WORKERS" => "1", **mysql_env } end run_cmd(scenario, ["bin/rails test", *TEST_ARGS].join(" "), verbose: true, env:) diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index 87380376..cb7cae47 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -13,10 +13,10 @@ test: primary: <<: *default tenanted: true - database: primary-%%{tenant} + database: <%= ENV.fetch('MYSQL_DB_PREFIX', 'primary') %>-%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default - database: shared + database: <%= ENV.fetch('MYSQL_DB_PREFIX', 'primary') %>-shared migrations_paths: "%{db_path}/shared_migrations" From f69a5c17d24d979f0bd14740e33e77047107e79e Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Tue, 4 Nov 2025 12:17:53 -0500 Subject: [PATCH 19/49] Make sure dbs start from a clean state everytime --- bin/test-integration | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/test-integration b/bin/test-integration index 235510be..c32dd291 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -93,6 +93,10 @@ def run_scenario(scenario) # set up the database and schema files mysql_env = ENV.select { |k, _| k.start_with?("MYSQL_") } mysql_env["MYSQL_DB_PREFIX"] = db_prefix + + # Drop existing databases to ensure fresh setup (like SQLite behavior) + run_cmd(scenario, "bin/rails db:drop", env: { "RAILS_ENV" => "test", **mysql_env }) rescue nil + run_cmd(scenario, "bin/rails db:prepare", env: { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "VERBOSE" => "true", **mysql_env }) # validate-ish the setup From bda859acceea2e2be750905a95b6bf1ea62cf1c5 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Tue, 4 Nov 2025 12:38:15 -0500 Subject: [PATCH 20/49] Enable integration tests for mysql and sqlite --- bin/test-integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/test-integration b/bin/test-integration index c32dd291..080b1240 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -22,7 +22,7 @@ require "debug" require "active_support" require "active_support/core_ext/object" # deep_dup -scenarios = Dir.glob("test/scenarios/mysql/*db/*record.rb").map do |path| +scenarios = Dir.glob("test/scenarios/*/*db/*record.rb").map do |path| adapter, database, models = path.scan(%r{test/scenarios/(.+)/(.+db)/(.+record).rb}).flatten { adapter: adapter, database: database, models: models } end From 7152457de2149cff0cb9c5f00e2c891159a12646 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 13:16:22 -0500 Subject: [PATCH 21/49] Add mysql schema_cache and make sure the unit tests use it --- test/scenarios/mysql/schema_cache.yml | 347 ++++++++++++++++++++++++++ test/test_helper.rb | 17 +- 2 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 test/scenarios/mysql/schema_cache.yml diff --git a/test/scenarios/mysql/schema_cache.yml b/test/scenarios/mysql/schema_cache.yml new file mode 100644 index 00000000..254e69f0 --- /dev/null +++ b/test/scenarios/mysql/schema_cache.yml @@ -0,0 +1,347 @@ +--- !ruby/object:ActiveRecord::ConnectionAdapters::SchemaCache +columns: + active_storage_attachments: + - &8 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: blob_id + cast_type: &1 !ruby/object:ActiveModel::Type::Integer + precision: + scale: + limit: 8 + max: 9223372036854775808 + min: -9223372036854775808 + sql_type_metadata: &3 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata + delegate_dc_obj: &2 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: bigint + type: :integer + limit: 8 + precision: + scale: + extra: '' + 'null': false + default: + default_function: + collation: + comment: + - &6 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: created_at + cast_type: &10 !ruby/object:ActiveRecord::Type::DateTime + precision: 6 + scale: + limit: + timezone: + sql_type_metadata: &11 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata + delegate_dc_obj: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: datetime(6) + type: :datetime + limit: + precision: 6 + scale: + extra: '' + 'null': false + default: + default_function: + collation: + comment: + - &7 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: id + cast_type: *1 + sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata + delegate_dc_obj: *2 + extra: auto_increment + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: name + cast_type: &4 !ruby/object:ActiveModel::Type::String + true: '1' + false: '0' + precision: + scale: + limit: 255 + sql_type_metadata: &5 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata + delegate_dc_obj: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: varchar(255) + type: :string + limit: 255 + precision: + scale: + extra: '' + 'null': false + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: record_id + cast_type: *1 + sql_type_metadata: *3 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: record_type + cast_type: *4 + sql_type_metadata: *5 + 'null': false + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + active_storage_blobs: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: byte_size + cast_type: *1 + sql_type_metadata: *3 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: checksum + cast_type: *4 + sql_type_metadata: *5 + 'null': true + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: content_type + cast_type: *4 + sql_type_metadata: *5 + 'null': true + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: filename + cast_type: *4 + sql_type_metadata: *5 + 'null': false + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - *7 + - &9 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: key + cast_type: *4 + sql_type_metadata: *5 + 'null': false + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: metadata + cast_type: &12 !ruby/object:ActiveRecord::Type::Text + true: t + false: f + precision: + scale: + limit: 65535 + sql_type_metadata: &13 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata + delegate_dc_obj: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: text + type: :text + limit: 65535 + precision: + scale: + extra: '' + 'null': true + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: service_name + cast_type: *4 + sql_type_metadata: *5 + 'null': false + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + active_storage_variant_records: + - *8 + - *7 + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: variation_digest + cast_type: *4 + sql_type_metadata: *5 + 'null': false + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + ar_internal_metadata: + - *6 + - *9 + - &14 !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: updated_at + cast_type: *10 + sql_type_metadata: *11 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: value + cast_type: *4 + sql_type_metadata: *5 + 'null': true + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + notes: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: body + cast_type: *12 + sql_type_metadata: *13 + 'null': true + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - *6 + - *7 + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: title + cast_type: *4 + sql_type_metadata: *5 + 'null': true + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - *14 + schema_migrations: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: version + cast_type: *4 + sql_type_metadata: *5 + 'null': false + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + users: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::Column + name: email + cast_type: *4 + sql_type_metadata: *5 + 'null': true + default: + default_function: + collation: utf8mb4_0900_ai_ci + comment: + - *7 + - *14 +primary_keys: + active_storage_attachments: id + active_storage_blobs: id + active_storage_variant_records: id + ar_internal_metadata: key + notes: id + schema_migrations: version + users: id +data_sources: + active_storage_attachments: true + active_storage_blobs: true + active_storage_variant_records: true + ar_internal_metadata: true + notes: true + schema_migrations: true + users: true +indexes: + active_storage_attachments: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::IndexDefinition + enabled: true + table: active_storage_attachments + name: index_active_storage_attachments_on_blob_id + unique: false + columns: + - blob_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: :btree + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::IndexDefinition + enabled: true + table: active_storage_attachments + name: index_active_storage_attachments_uniqueness + unique: true + columns: + - record_type + - record_id + - name + - blob_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: :btree + include: + nulls_not_distinct: + comment: + valid: true + active_storage_blobs: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::IndexDefinition + enabled: true + table: active_storage_blobs + name: index_active_storage_blobs_on_key + unique: true + columns: + - key + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: :btree + include: + nulls_not_distinct: + comment: + valid: true + active_storage_variant_records: + - !ruby/object:ActiveRecord::ConnectionAdapters::MySQL::IndexDefinition + enabled: true + table: active_storage_variant_records + name: index_active_storage_variant_records_uniqueness + unique: true + columns: + - blob_id + - variation_digest + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: :btree + include: + nulls_not_distinct: + comment: + valid: true + ar_internal_metadata: [] + notes: [] + schema_migrations: [] + users: [] +version: 20250222215621 diff --git a/test/test_helper.rb b/test/test_helper.rb index 014f4333..641a4cdb 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -80,7 +80,6 @@ def for_each_scenario(s = all_scenarios, except: {}, only: {}, &block) end def all_scenarios - filter_adapter = ENV["TEST_ADAPTER"] Dir.glob(File.join(__dir__, "scenarios", "*", "*", "database.yml")) .each_with_object({}) do |db_config_path, scenarios| db_config_dir = File.dirname(db_config_path) @@ -88,8 +87,6 @@ def all_scenarios db_scenario = File.basename(db_config_dir) model_files = Dir.glob(File.join(db_config_dir, "*.rb")) - next if filter_adapter && db_adapter != filter_adapter - scenarios["#{db_adapter}/#{db_scenario}"] = model_files.map { File.basename(_1, ".*") } end end @@ -240,15 +237,17 @@ def base_config end def with_schema_dump_file - schema_file = Dir.glob("test/scenarios/*/schema.rb").first - FileUtils.cp schema_file, - ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(base_config) + Dir.glob("test/scenarios/*/schema.rb").each do |schema_file| + FileUtils.cp schema_file, + ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(base_config) + end end def with_schema_cache_dump_file - cache_file = Dir.glob("test/scenarios/*/schema_cache.yml").first - FileUtils.cp cache_file, - ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(base_config) + Dir.glob("test/scenarios/*/schema_cache.yml").each do |cache_file| + FileUtils.cp cache_file, + ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(base_config) + end end def with_new_migration_file From d47aa8c873ffeccf0f15d7b39a49cc9519ae696f Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 13:32:17 -0500 Subject: [PATCH 22/49] Refactor cleaning up databases in unit tests --- test/test_helper.rb | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 641a4cdb..9240bcda 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -283,9 +283,9 @@ def capture_rails_log private def cleanup_tenant_databases base_config = all_configs.find { |c| c.configuration_hash[:tenanted] } - return unless base_config - tenants = base_config.tenants + return if tenants.empty? + tenants.each do |tenant_name| adapter = base_config.new_tenant_config(tenant_name).config_adapter adapter.drop_database @@ -296,24 +296,10 @@ def cleanup_tenant_databases def cleanup_shared_databases shared_configs = all_configs.reject { |c| c.configuration_hash[:tenanted] || c.database.blank? } + return if shared_configs.empty? + shared_configs.each do |config| - if db_adapter == "mysql" - ActiveRecord::Tasks::DatabaseTasks.drop(config) - else - pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool( - config.name, - role: ActiveRecord.writing_role, - shard: ActiveRecord::Base.default_shard, - strict: false - ) - next unless pool - - conn = pool.connection - tables = conn.tables - [ "ar_internal_metadata", "schema_migrations" ] - tables.each do |table| - conn.execute("DELETE FROM #{conn.quote_table_name(table)}") - end - end + ActiveRecord::Tasks::DatabaseTasks.drop(config) rescue => e Rails.logger.warn "Failed to cleanup shared database #{config.name}: #{e.message}" end From 8418c24434c1fa6e3d426eec02271b93237f66dd Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 13:44:19 -0500 Subject: [PATCH 23/49] Rename methods --- test/test_helper.rb | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 9240bcda..58b7f5ac 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -147,8 +147,8 @@ def with_db_scenario(db_scenario, &block) end teardown do - cleanup_shared_databases - cleanup_tenant_databases + drop_shared_databases + drop_tentant_databases ActiveRecord::Migration.verbose = @migration_verbose_was ActiveRecord::Base.configurations = @old_configurations ActiveRecord::Tasks::DatabaseTasks.db_dir = @old_db_dir @@ -281,7 +281,18 @@ def capture_rails_log end private - def cleanup_tenant_databases + def drop_shared_databases + shared_configs = all_configs.reject { |c| c.configuration_hash[:tenanted] || c.database.blank? } + return if shared_configs.empty? + + shared_configs.each do |config| + ActiveRecord::Tasks::DatabaseTasks.drop(config) + rescue => e + Rails.logger.warn "Failed to cleanup shared database #{config.name}: #{e.message}" + end + end + + def drop_tentant_databases base_config = all_configs.find { |c| c.configuration_hash[:tenanted] } tenants = base_config.tenants return if tenants.empty? @@ -294,16 +305,6 @@ def cleanup_tenant_databases end end - def cleanup_shared_databases - shared_configs = all_configs.reject { |c| c.configuration_hash[:tenanted] || c.database.blank? } - return if shared_configs.empty? - - shared_configs.each do |config| - ActiveRecord::Tasks::DatabaseTasks.drop(config) - rescue => e - Rails.logger.warn "Failed to cleanup shared database #{config.name}: #{e.message}" - end - end def create_fake_record # emulate models like ActiveStorage::Record that inherit directly from AR::Base From 31fe774a4d02e4d3c594a20e3625dd97898b7dbc Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 13:48:04 -0500 Subject: [PATCH 24/49] Simplify SystemTest setup --- .../smarty/test/application_system_test_case.rb | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/test/smarty/test/application_system_test_case.rb b/test/smarty/test/application_system_test_case.rb index d351821f..e9e3db2a 100644 --- a/test/smarty/test/application_system_test_case.rb +++ b/test/smarty/test/application_system_test_case.rb @@ -1,19 +1,10 @@ require "test_helper" -Capybara.server = :puma, { Silent: true } +Capybara.server = :puma, { Silent: true } # suppress server boot announcement class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - if ENV["CHROMEDRIVER_PATH"] - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] do |driver_option| - driver_option.add_argument("--disable-dev-shm-usage") - driver_option.add_argument("--no-sandbox") - end - - Selenium::WebDriver::Chrome::Service.driver_path = ENV["CHROMEDRIVER_PATH"] - else - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] do |driver_option| - driver_option.add_argument("--disable-dev-shm-usage") - driver_option.add_argument("--no-sandbox") - end + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] do |driver_option| + driver_option.add_argument("--disable-dev-shm-usage") + driver_option.add_argument("--no-sandbox") end end From e004cd94fb68e11936bb991fef486e9d7e9ece06 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 13:50:13 -0500 Subject: [PATCH 25/49] Revert un-needed change --- test/integration/test/active_job_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/test/active_job_test.rb b/test/integration/test/active_job_test.rb index 8a2ea58f..5e4bb38e 100644 --- a/test/integration/test/active_job_test.rb +++ b/test/integration/test/active_job_test.rb @@ -26,7 +26,7 @@ class NoteCheerioJobTest < ActiveJob::TestCase end test "global id locator catches wrong tenant context" do - tenant = "global_id_locator_catches_wrong_tenant" + tenant = __method__ note = ApplicationRecord.create_tenant(tenant) do Note.create!(title: "asdf", body: "Lorem ipsum.") end @@ -40,7 +40,7 @@ class NoteCheerioJobTest < ActiveJob::TestCase end test "global id locator catches untenanted context" do - tenant = "global_id_locator_catches_untenanted_context" + tenant = __method__ note = ApplicationRecord.create_tenant(tenant) do Note.create!(title: "asdf", body: "Lorem ipsum.") end From b22fa30709ae33220fd7dab50a03dab0d434af59 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 13:57:12 -0500 Subject: [PATCH 26/49] More cleanup --- .../tenanted/database_adapters/mysql.rb | 2 +- .../database_configurations/tenant_config.rb | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index 659fead9..dad81827 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -2,7 +2,7 @@ module ActiveRecord module Tenanted - module DatabaseAdapters + module DatabaseAdapters # :nodoc: class MySQL attr_reader :db_config diff --git a/lib/active_record/tenanted/database_configurations/tenant_config.rb b/lib/active_record/tenanted/database_configurations/tenant_config.rb index d1129b7b..93a4f835 100644 --- a/lib/active_record/tenanted/database_configurations/tenant_config.rb +++ b/lib/active_record/tenanted/database_configurations/tenant_config.rb @@ -13,20 +13,6 @@ def tenant configuration_hash.fetch(:tenant) end - def database - configuration_hash.fetch(:database) - end - - def configuration_hash - hash = super - if hash[:database]&.include?("%{tenant}") - hash = hash.dup - tenant_name = hash.fetch(:tenant) - hash[:database] = sprintf(hash[:database], tenant: tenant_name) - end - hash - end - def config_adapter @config_adapter ||= ActiveRecord::Tenanted::DatabaseAdapter.new(self) end From 6f4602326d32c5984dcd99c8aa253f9c5134cced Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 14:09:15 -0500 Subject: [PATCH 27/49] Simplify --- lib/active_record/tenanted/database_adapters/mysql.rb | 6 ------ lib/active_record/tenanted/tenant.rb | 8 +------- test/test_helper.rb | 1 - 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index dad81827..a356ebd5 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -90,12 +90,6 @@ def drop_database ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(database_path)}") end - rescue Mysql2::Error => e - if e.message.include?("Can't drop database") || e.message.include?("doesn't exist") - Rails.logger.debug "Database #{database_path} doesn't exist or already dropped" - else - raise - end end def database_exist? diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 0208ce5d..dc91b979 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -158,10 +158,6 @@ def create_tenant(tenant_name, if_not_exists: false, &block) end def destroy_tenant(tenant_name) - adapter = tenanted_root_config.new_tenant_config(tenant_name).config_adapter - - return unless adapter.database_exist? - ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database" with_tenant(tenant_name, prohibit_shard_swapping: false) do @@ -170,9 +166,7 @@ def destroy_tenant(tenant_name) end end - adapter.drop_database - rescue => e - Rails.logger.warn "Failed to destroy tenant #{tenant_name}: #{e.message}" + tenanted_root_config.new_tenant_config(tenant_name).config_adapter.drop_database end def tenants diff --git a/test/test_helper.rb b/test/test_helper.rb index 58b7f5ac..eabbe931 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -305,7 +305,6 @@ def drop_tentant_databases end end - def create_fake_record # emulate models like ActiveStorage::Record that inherit directly from AR::Base Object.const_set(:FakeRecord, Class.new(ActiveRecord::Base)) From 798c40d506cb117e0883977a768262f4741b2104 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 14:32:19 -0500 Subject: [PATCH 28/49] Add test to ensure shared dbs don't get passed to adapter --- test/unit/database_configurations_test.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit/database_configurations_test.rb b/test/unit/database_configurations_test.rb index dcc7554a..c29ce06f 100644 --- a/test/unit/database_configurations_test.rb +++ b/test/unit/database_configurations_test.rb @@ -219,6 +219,21 @@ def assert_all_tenants_found assert_equal([ "bar" ], base_config.tenants) end + + test "only returns tenants from tenanted databases, not shared databases" do + assert_empty(base_config.tenants) + + TenantedApplicationRecord.create_tenant("foo") + + assert_equal([ "foo" ], base_config.tenants) + + all_databases = ActiveRecord::Base.configurations.configs_for(env_name: base_config.env_name) + + untenanted_config = all_databases.reject { |c| c.configuration_hash[:tenanted] } + untenanted_config.each do |shared_config| + assert_not_includes(base_config.tenants, shared_config.database) + end + end end end From 84fd8309c8517902a0b627d2bd91bd11c8cf8e67 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 14:56:37 -0500 Subject: [PATCH 29/49] Add tests to ensure we get a valid mysql database name --- .../tenanted/database_adapters/mysql.rb | 11 -- test/unit/database_configurations_test.rb | 101 ++++++++++++++++++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index a356ebd5..669797d1 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -62,17 +62,6 @@ def validate_tenant_name(tenant_name) if database_name.match?(/^\d/) raise ActiveRecord::Tenanted::BadTenantNameError, "Database name cannot start with a number: #{database_name.inspect}" end - - reserved_words = %w[ - database databases table tables column columns index indexes - select insert update delete create drop alter - user users group groups order by from where - and or not null true false - ] - - if reserved_words.include?(database_name.downcase) - raise ActiveRecord::Tenanted::BadTenantNameError, "Database name is a reserved MySQL keyword: #{database_name.inspect}" - end end def create_database diff --git a/test/unit/database_configurations_test.rb b/test/unit/database_configurations_test.rb index c29ce06f..e2ded3d2 100644 --- a/test/unit/database_configurations_test.rb +++ b/test/unit/database_configurations_test.rb @@ -182,6 +182,107 @@ def assert_all_tenants_found end end + describe "MySQL" do + let(:adapter) { "mysql2" } + + describe "database_for" do + describe "validation" do + let(:database) { "test_%{tenant}_db" } + + test "raises if the tenant name contains a forward slash" do + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo/bar") } + end + + test "raises if the tenant name contains a backslash" do + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo\\bar") } + end + + test "raises if the resulting database name ends with a period" do + config_with_period = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: "%{tenant}." } + ) + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config_with_period.database_for("foo") } + end + + test "raises if the tenant name contains ASCII NUL or control characters" do + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo\x00bar") } + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo\x01bar") } + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo\nbar") } + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo\tbar") } + end + + test "raises if the tenant name contains spaces" do + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo bar") } + end + + test "raises if the tenant name contains special characters" do + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo@bar") } + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo#bar") } + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo!bar") } + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for("foo*bar") } + end + + test "raises if the resulting database name is too long (>64 chars)" do + long_name = "a" * 100 + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_for(long_name) } + end + + test "raises if the resulting database name starts with a number" do + config_with_prefix = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: "%{tenant}_db" } + ) + assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config_with_prefix.database_for("123") } + end + + test "allows valid characters: letters, numbers, underscore, dollar, and hyphen" do + assert_nothing_raised { config.database_for("foo_bar") } + assert_nothing_raised { config.database_for("foo-bar") } + assert_nothing_raised { config.database_for("foo$bar") } + assert_nothing_raised { config.database_for("foo123") } + end + end + + describe "database name pattern" do + let(:database) { "tenanted_%{tenant}_db" } + + test "returns the database name for a tenant" do + assert_equal("tenanted_foo_db", config.database_for("foo")) + end + + test "works with hyphens in the pattern" do + config_with_hyphens = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: "tenanted-%{tenant}-db" } + ) + assert_equal("tenanted-foo-db", config_with_hyphens.database_for("foo")) + end + + describe "parallel test workers" do + setup { config.test_worker_id = 99 } + + test "returns the worker-specific database name for a tenant" do + assert_equal("tenanted_foo_db_99", config.database_for("foo")) + end + + test "appends worker id to the end of the database name" do + config_with_pattern = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: "prefix_%{tenant}_suffix" } + ) + config_with_pattern.test_worker_id = 42 + assert_equal("prefix_bar_suffix_42", config_with_pattern.database_for("bar")) + end + end + end + end + end + describe "max_connection_pools" do test "defaults to 50" do config_hash = { adapter: "sqlite3", database: "database" } From bc76b0e248da3cd5a5bb6e765f76c047dd4c4d39 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 15:09:10 -0500 Subject: [PATCH 30/49] Add tests for when a host option is provided and more cleanup --- .../tenanted/database_adapters/mysql.rb | 3 +- .../database_configurations/base_config.rb | 11 ++-- test/unit/database_configurations_test.rb | 59 +++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index 669797d1..7312a062 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -38,8 +38,7 @@ def tenant_databases end end end - rescue ActiveRecord::NoDatabaseError, Mysql2::Error => e - Rails.logger.warn "Could not list tenant databases: #{e.message}" + rescue ActiveRecord::NoDatabaseError, Mysql2::Error [] end end diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 5790aff1..1bfa96ec 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -36,11 +36,6 @@ def database_for(tenant_name) db end - def host_for(tenant_name) - return nil unless host&.include?("%{tenant}") - sprintf(host, tenant: tenant_name) - end - def tenants all_databases = ActiveRecord::Base.configurations.configs_for(env_name: env_name) untenanted = all_databases.reject { |c| c.configuration_hash[:tenanted] }.filter_map(&:database) @@ -74,6 +69,12 @@ def new_connection def max_connection_pools (configuration_hash[:max_connection_pools] || DEFAULT_MAX_CONNECTION_POOLS).to_i end + + private + def host_for(tenant_name) + return nil unless host&.include?("%{tenant}") + sprintf(host, tenant: tenant_name) + end end end end diff --git a/test/unit/database_configurations_test.rb b/test/unit/database_configurations_test.rb index e2ded3d2..2aa48849 100644 --- a/test/unit/database_configurations_test.rb +++ b/test/unit/database_configurations_test.rb @@ -283,6 +283,65 @@ def assert_all_tenants_found end end + describe "new_tenant_config" do + describe "with_host" do + let(:adapter) { "mysql2" } + let(:database) { "tenanted_%{tenant}_db" } + + test "preserves static host in tenant config" do + config = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: database, host: "mysql.example.com" } + ) + + tenant_config = config.new_tenant_config("foo") + + assert_equal("mysql.example.com", tenant_config.host) + assert_equal("tenanted_foo_db", tenant_config.database) + end + + test "formats host with tenant placeholder in tenant config" do + config = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: database, host: "%{tenant}.mysql.example.com" } + ) + + tenant_config = config.new_tenant_config("foo") + + assert_equal("foo.mysql.example.com", tenant_config.host) + assert_equal("tenanted_foo_db", tenant_config.database) + end + + test "formats host correctly for different tenant names" do + config = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: database, host: "%{tenant}.mysql.example.com" } + ) + + foo_config = config.new_tenant_config("foo") + bar_config = config.new_tenant_config("bar") + + assert_equal("foo.mysql.example.com", foo_config.host) + assert_equal("bar.mysql.example.com", bar_config.host) + end + + test "does not include host in tenant config when not provided" do + config = ActiveRecord::Tenanted::DatabaseConfigurations::BaseConfig.new( + "test", + "test_tenant", + { adapter: adapter, database: database } + ) + + tenant_config = config.new_tenant_config("foo") + + assert_nil(tenant_config.host) + end + end + end + describe "max_connection_pools" do test "defaults to 50" do config_hash = { adapter: "sqlite3", database: "database" } From 1027b04f09f158bd32e58aa10ea352dc9085675f Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 5 Nov 2025 15:46:35 -0500 Subject: [PATCH 31/49] Move comment --- .../tenanted/database_configurations/base_config.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 1bfa96ec..608764a7 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -52,7 +52,6 @@ def new_tenant_config(tenant_name) hash[:tenant] = tenant_name hash[:database] = database_for(tenant_name) hash[:tenanted_config_name] = name - # Only override host if it contains a tenant template new_host = host_for(tenant_name) hash[:host] = new_host if new_host end @@ -72,6 +71,7 @@ def max_connection_pools private def host_for(tenant_name) + # Only override host if it contains a tenant template return nil unless host&.include?("%{tenant}") sprintf(host, tenant: tenant_name) end From 8ce81ffdaa63bc6d81e3afc988b2c89b40345286 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 06:21:16 -0500 Subject: [PATCH 32/49] Refactor test-integration setup for mysql --- bin/test-integration | 23 +++++++++++--------- test/scenarios/mysql/primary_db/database.yml | 6 ++--- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 080b1240..6b0cfc4b 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -22,7 +22,7 @@ require "debug" require "active_support" require "active_support/core_ext/object" # deep_dup -scenarios = Dir.glob("test/scenarios/*/*db/*record.rb").map do |path| +scenarios = Dir.glob("test/scenarios/mysql/*db/*record.rb").map do |path| adapter, database, models = path.scan(%r{test/scenarios/(.+)/(.+db)/(.+record).rb}).flatten { adapter: adapter, database: database, models: models } end @@ -92,26 +92,29 @@ def run_scenario(scenario) # set up the database and schema files mysql_env = ENV.select { |k, _| k.start_with?("MYSQL_") } - mysql_env["MYSQL_DB_PREFIX"] = db_prefix + mysql_env["MYSQL_UNIQUE_PREFIX"] = db_prefix - # Drop existing databases to ensure fresh setup (like SQLite behavior) - run_cmd(scenario, "bin/rails db:drop", env: { "RAILS_ENV" => "test", **mysql_env }) rescue nil + adapter_config = if scenario[:adapter] == "mysql" + { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "MYSQL_UNIQUE_PREFIX" => db_prefix } + else + {} + end - run_cmd(scenario, "bin/rails db:prepare", env: { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "VERBOSE" => "true", **mysql_env }) + # Drop existing databases to ensure fresh setup + run_cmd(scenario, "bin/rails db:drop", env: { **adapter_config }) rescue nil + + run_cmd(scenario, "bin/rails db:prepare", env: { **adapter_config }) # validate-ish the setup prefix = scenario[:database].match?(/\Asecondary/) ? "tenanted_" : "" File.exist?("db/#{prefix}schema.rb") || abort("Schema dump not generated") File.exist?("db/#{prefix}schema_cache.yml") || abort("Schema cache dump not generated") - # create a fake tenant database to validate that it is deleted in the test suite - run_cmd(scenario, %{bin/rails runner "ApplicationRecord.tenanted_root_config.new_tenant_config('delete-me').config_adapter.create_database"}, env: { "RAILS_ENV" => "test", **mysql_env }) - # run in parallel half the time env = if rand(2).zero? - { "PARALLEL_WORKERS" => "2", **mysql_env } + { "PARALLEL_WORKERS" => "2" } else - { "PARALLEL_WORKERS" => "1", **mysql_env } + { "PARALLEL_WORKERS" => "1" } end run_cmd(scenario, ["bin/rails test", *TEST_ARGS].join(" "), verbose: true, env:) diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index cb7cae47..62a2cba7 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -5,7 +5,7 @@ default: &default max_connections: 20 username: root password: devcontainer - host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> + host: mysql port: 3306 sslmode: disabled @@ -13,10 +13,10 @@ test: primary: <<: *default tenanted: true - database: <%= ENV.fetch('MYSQL_DB_PREFIX', 'primary') %>-%%{tenant} + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'primary') %>-%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default - database: <%= ENV.fetch('MYSQL_DB_PREFIX', 'primary') %>-shared + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'primary') %>-shared migrations_paths: "%{db_path}/shared_migrations" From bb2b311eeaa0e1677e01b681b5a4a8e68d32cb97 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 11:26:29 -0500 Subject: [PATCH 33/49] Simplify migration logic --- lib/active_record/tenanted/database_tasks.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index da3daaff..8d962961 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -111,9 +111,7 @@ def migrate(config) end if Rails.env.development? || ENV["ARTENANT_SCHEMA_DUMP"].present? - should_dump_schema = migrated || !database_already_initialized || (config.primary? && ENV["ARTENANT_SCHEMA_DUMP"].present?) - - if should_dump_schema + if migrated || !database_already_initialized ActiveRecord::Tasks::DatabaseTasks.dump_schema(config) end From 6431651a2a8ef5027edad1646b2828d341d1d96e Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 11:58:03 -0500 Subject: [PATCH 34/49] Improve the test coverage of database_tasks --- test/unit/database_tasks_test.rb | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/unit/database_tasks_test.rb b/test/unit/database_tasks_test.rb index d23ae69b..b638d25a 100644 --- a/test/unit/database_tasks_test.rb +++ b/test/unit/database_tasks_test.rb @@ -3,6 +3,44 @@ require "test_helper" describe ActiveRecord::Tenanted::DatabaseTasks do + describe "#drop_tenant" do + for_each_scenario do + setup do + base_config.new_tenant_config("foo").config_adapter.create_database + end + + test "drops the specified tenant database" do + config = base_config.new_tenant_config("foo") + assert_predicate config.config_adapter, :database_exist? + + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).drop_tenant("foo") + + assert_not_predicate config.config_adapter, :database_exist? + end + end + end + + describe "#drop_all" do + for_each_scenario do + let(:tenants) { %w[foo bar baz] } + + setup do + tenants.each do |tenant| + TenantedApplicationRecord.create_tenant(tenant) + end + end + + test "drops all tenant databases" do + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).drop_all + + tenants.each do |tenant| + config = base_config.new_tenant_config(tenant) + assert_not_predicate config.config_adapter, :database_exist? + end + end + end + end + describe ".migrate_tenant" do for_each_scenario do setup do @@ -32,6 +70,15 @@ end end + test "skips migration when no pending migrations" do + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).migrate_tenant("foo") + + ActiveRecord::Migration.verbose = true + assert_silent do + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).migrate_tenant("foo") + end + end + test "database schema file should be created" do config = base_config.new_tenant_config("foo") schema_path = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(config) @@ -54,6 +101,19 @@ assert(File.exist?(schema_cache_path)) end + test "does not recreate schema cache when up to date" do + config = base_config.new_tenant_config("foo") + schema_cache_path = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(config) + + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).migrate_tenant("foo") + original_mtime = File.mtime(schema_cache_path) + + sleep 0.1 + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).migrate_tenant("foo") + + assert_equal original_mtime, File.mtime(schema_cache_path) + end + describe "when schema dump file exists" do setup { with_schema_dump_file } @@ -88,6 +148,8 @@ end end + + describe "when an outdated schema cache dump file exists" do setup { with_schema_cache_dump_file } setup { with_new_migration_file } From 554f3782130c52f2b4328627c56a415c95a24525 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 13:27:52 -0500 Subject: [PATCH 35/49] Refactor and clean up --- bin/test-integration | 5 +++-- lib/active_record/tenanted/database_tasks.rb | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 6b0cfc4b..0d4f0a00 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -94,6 +94,7 @@ def run_scenario(scenario) mysql_env = ENV.select { |k, _| k.start_with?("MYSQL_") } mysql_env["MYSQL_UNIQUE_PREFIX"] = db_prefix + # For MySQL, we need to ensure that each database has a unique name otherwise we'll have contention in the test suite and everything will get locked up. adapter_config = if scenario[:adapter] == "mysql" { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "MYSQL_UNIQUE_PREFIX" => db_prefix } else @@ -112,9 +113,9 @@ def run_scenario(scenario) # run in parallel half the time env = if rand(2).zero? - { "PARALLEL_WORKERS" => "2" } + { "PARALLEL_WORKERS" => "2" , **adapter_config} else - { "PARALLEL_WORKERS" => "1" } + { "PARALLEL_WORKERS" => "1" , **adapter_config } end run_cmd(scenario, ["bin/rails test", *TEST_ARGS].join(" "), verbose: true, env:) diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index 8d962961..880c34ac 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -102,6 +102,7 @@ def migrate(config) end end + # migrate migrated = false if pool.migration_context.pending_migration_versions.present? @@ -110,6 +111,7 @@ def migrate(config) migrated = true end + # dump the schema and schema cache if Rails.env.development? || ENV["ARTENANT_SCHEMA_DUMP"].present? if migrated || !database_already_initialized ActiveRecord::Tasks::DatabaseTasks.dump_schema(config) From c9c421a6cf46cda5315c585ec647d63508711c4e Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 14:13:19 -0500 Subject: [PATCH 36/49] Run both types of adapters in the integration tests --- bin/test-integration | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index 0d4f0a00..879ac7de 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -22,7 +22,7 @@ require "debug" require "active_support" require "active_support/core_ext/object" # deep_dup -scenarios = Dir.glob("test/scenarios/mysql/*db/*record.rb").map do |path| +scenarios = Dir.glob("test/scenarios/*/*db/*record.rb").map do |path| adapter, database, models = path.scan(%r{test/scenarios/(.+)/(.+db)/(.+record).rb}).flatten { adapter: adapter, database: database, models: models } end @@ -90,11 +90,6 @@ def run_scenario(scenario) Bundler.with_original_env do run_cmd(scenario, "bundle check || bundle install") - # set up the database and schema files - mysql_env = ENV.select { |k, _| k.start_with?("MYSQL_") } - mysql_env["MYSQL_UNIQUE_PREFIX"] = db_prefix - - # For MySQL, we need to ensure that each database has a unique name otherwise we'll have contention in the test suite and everything will get locked up. adapter_config = if scenario[:adapter] == "mysql" { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "MYSQL_UNIQUE_PREFIX" => db_prefix } else From c47579c8a30e5c57710e60555f2bbbb09b3d95e2 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 14:13:47 -0500 Subject: [PATCH 37/49] Reject the connection if the tenant is an empty string --- lib/active_record/tenanted/cable_connection.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/active_record/tenanted/cable_connection.rb b/lib/active_record/tenanted/cable_connection.rb index 7679b566..d301ff15 100644 --- a/lib/active_record/tenanted/cable_connection.rb +++ b/lib/active_record/tenanted/cable_connection.rb @@ -19,9 +19,10 @@ def connect private def set_current_tenant - return unless tenant = tenant_resolver.call(request) + tenant = tenant_resolver.call(request) + return if tenant.nil? - if connection_class.tenant_exist?(tenant) + if tenant.present? && connection_class.tenant_exist?(tenant) self.current_tenant = tenant else reject_unauthorized_connection From 18ca09c76f627774b7dccc87e056f45261705db7 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 14:49:36 -0500 Subject: [PATCH 38/49] Configure CI to add mysql --- .github/workflows/ci.yml | 30 ++++++++++++++++++++ test/scenarios/mysql/primary_db/database.yml | 6 ++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b1907b2..703e7ae3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,19 @@ jobs: unit: runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: devcontainer + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=3 steps: - uses: actions/checkout@v5 - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 @@ -35,9 +48,24 @@ jobs: ruby-version: "3.4" bundler-cache: true - run: bin/test-unit + env: + MYSQL_HOST: 127.0.0.1 integration: runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: devcontainer + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=3 steps: - uses: actions/checkout@v5 - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 @@ -45,3 +73,5 @@ jobs: ruby-version: "3.4" bundler-cache: true - run: bin/test-integration + env: + MYSQL_HOST: 127.0.0.1 diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index 62a2cba7..267da0b3 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -5,7 +5,7 @@ default: &default max_connections: 20 username: root password: devcontainer - host: mysql + host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> port: 3306 sslmode: disabled @@ -13,10 +13,10 @@ test: primary: <<: *default tenanted: true - database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'primary') %>-%%{tenant} + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'test') %>-%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default - database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'primary') %>-shared + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'test') %>-shared migrations_paths: "%{db_path}/shared_migrations" From cb16d877e017a6318db9cdd1c8693b6502b25c10 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Thu, 6 Nov 2025 14:49:52 -0500 Subject: [PATCH 39/49] Align testing adapters more closely and add more test scenarios to mysql --- bin/test-integration | 13 +++++------ .../mysql/primary_named_db/database.yml | 23 +++++++++++++++++++ .../mysql/primary_named_db/primary_record.rb | 22 ++++++++++++++++++ .../primary_named_db/secondary_record.rb | 22 ++++++++++++++++++ .../20250203191207_create_announcements.rb | 11 +++++++++ .../20250203191115_create_users.rb | 11 +++++++++ .../scenarios/mysql/secondary_db/database.yml | 23 +++++++++++++++++++ .../mysql/secondary_db/primary_record.rb | 22 ++++++++++++++++++ .../mysql/secondary_db/secondary_record.rb | 22 ++++++++++++++++++ .../20250203191207_create_announcements.rb | 11 +++++++++ .../20250203191115_create_users.rb | 11 +++++++++ 11 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 test/scenarios/mysql/primary_named_db/database.yml create mode 100644 test/scenarios/mysql/primary_named_db/primary_record.rb create mode 100644 test/scenarios/mysql/primary_named_db/secondary_record.rb create mode 100644 test/scenarios/mysql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/mysql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/scenarios/mysql/secondary_db/database.yml create mode 100644 test/scenarios/mysql/secondary_db/primary_record.rb create mode 100644 test/scenarios/mysql/secondary_db/secondary_record.rb create mode 100644 test/scenarios/mysql/secondary_db/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/mysql/secondary_db/tenanted_migrations/20250203191115_create_users.rb diff --git a/bin/test-integration b/bin/test-integration index 879ac7de..e41e89a3 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -90,11 +90,10 @@ def run_scenario(scenario) Bundler.with_original_env do run_cmd(scenario, "bundle check || bundle install") - adapter_config = if scenario[:adapter] == "mysql" - { "RAILS_ENV" => "test", "ARTENANT_SCHEMA_DUMP" => "1", "MYSQL_UNIQUE_PREFIX" => db_prefix } - else - {} - end + adapter_config = {"RAILS_ENV" => "test","ARTENANT_SCHEMA_DUMP" => "1"} + if scenario[:adapter] == "mysql" + adapter_config.merge!({ "MYSQL_UNIQUE_PREFIX" => db_prefix }) + end # Drop existing databases to ensure fresh setup run_cmd(scenario, "bin/rails db:drop", env: { **adapter_config }) rescue nil @@ -108,9 +107,9 @@ def run_scenario(scenario) # run in parallel half the time env = if rand(2).zero? - { "PARALLEL_WORKERS" => "2" , **adapter_config} + { "PARALLEL_WORKERS" => "2", **adapter_config } else - { "PARALLEL_WORKERS" => "1" , **adapter_config } + { "PARALLEL_WORKERS" => "1", **adapter_config } end run_cmd(scenario, ["bin/rails test", *TEST_ARGS].join(" "), verbose: true, env:) diff --git a/test/scenarios/mysql/primary_named_db/database.yml b/test/scenarios/mysql/primary_named_db/database.yml new file mode 100644 index 00000000..38cf22c0 --- /dev/null +++ b/test/scenarios/mysql/primary_named_db/database.yml @@ -0,0 +1,23 @@ +default: &default + adapter: mysql2 + encoding: utf8mb4 + pool: 20 + max_connections: 20 + username: root + password: devcontainer + host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> + port: 3306 + sslmode: disabled + +test: + tenanted: + <<: *default + tenanted: true + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'test') %>-%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + shared: + <<: *default + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'test') %>-shared + migrations_paths: "%{db_path}/shared_migrations" + + diff --git a/test/scenarios/mysql/primary_named_db/primary_record.rb b/test/scenarios/mysql/primary_named_db/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/mysql/primary_named_db/primary_record.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + primary_abstract_class + + tenanted "tenanted" +end + +class User < TenantedApplicationRecord +end + +class Post < TenantedApplicationRecord +end + +class SharedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: { writing: :shared } +end + +class Announcement < SharedApplicationRecord +end diff --git a/test/scenarios/mysql/primary_named_db/secondary_record.rb b/test/scenarios/mysql/primary_named_db/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/mysql/primary_named_db/secondary_record.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + tenanted "tenanted" +end + +class User < TenantedApplicationRecord +end + +class Post < TenantedApplicationRecord +end + +class SharedApplicationRecord < ActiveRecord::Base + primary_abstract_class + + connects_to database: { writing: :shared } +end + +class Announcement < SharedApplicationRecord +end diff --git a/test/scenarios/mysql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/mysql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/mysql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateAnnouncements < ActiveRecord::Migration[8.1] + def change + create_table :announcements do |t| + t.string :message + + t.timestamps + end + end +end diff --git a/test/scenarios/mysql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/mysql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/mysql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email + + t.timestamps + end + end +end diff --git a/test/scenarios/mysql/secondary_db/database.yml b/test/scenarios/mysql/secondary_db/database.yml new file mode 100644 index 00000000..a0e6abfd --- /dev/null +++ b/test/scenarios/mysql/secondary_db/database.yml @@ -0,0 +1,23 @@ +default: &default + adapter: mysql2 + encoding: utf8mb4 + pool: 20 + max_connections: 20 + username: root + password: devcontainer + host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> + port: 3306 + sslmode: disabled + +test: + shared: + <<: *default + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'test') %>-shared + migrations_paths: "%{db_path}/shared_migrations" + tenanted: + <<: *default + tenanted: true + database: <%= ENV.fetch('MYSQL_UNIQUE_PREFIX', 'test') %>-%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + + diff --git a/test/scenarios/mysql/secondary_db/primary_record.rb b/test/scenarios/mysql/secondary_db/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/mysql/secondary_db/primary_record.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + primary_abstract_class + + tenanted "tenanted" +end + +class User < TenantedApplicationRecord +end + +class Post < TenantedApplicationRecord +end + +class SharedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: { writing: :shared } +end + +class Announcement < SharedApplicationRecord +end diff --git a/test/scenarios/mysql/secondary_db/secondary_record.rb b/test/scenarios/mysql/secondary_db/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/mysql/secondary_db/secondary_record.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + tenanted "tenanted" +end + +class User < TenantedApplicationRecord +end + +class Post < TenantedApplicationRecord +end + +class SharedApplicationRecord < ActiveRecord::Base + primary_abstract_class + + connects_to database: { writing: :shared } +end + +class Announcement < SharedApplicationRecord +end diff --git a/test/scenarios/mysql/secondary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/mysql/secondary_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/mysql/secondary_db/shared_migrations/20250203191207_create_announcements.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateAnnouncements < ActiveRecord::Migration[8.1] + def change + create_table :announcements do |t| + t.string :message + + t.timestamps + end + end +end diff --git a/test/scenarios/mysql/secondary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/mysql/secondary_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/mysql/secondary_db/tenanted_migrations/20250203191115_create_users.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email + + t.timestamps + end + end +end From 2e8b21cb4ee0382bf9990a57af2689d427b45820 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 10:26:51 -0500 Subject: [PATCH 40/49] Fix test and ensure temp connection is more obviously set (and exclude it from the tasks) --- bin/test-integration | 6 +++--- lib/active_record/tenanted/database_adapters/mysql.rb | 7 +++++-- test/test_helper.rb | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bin/test-integration b/bin/test-integration index e41e89a3..635445c3 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -30,7 +30,7 @@ end COLOR_FG_BLUE = "\e[0;34m" COLOR_RESET = "\e[0m" -def run_cmd(scenario, cmd, verbose: true, env: {}) +def run_cmd(scenario, cmd, verbose: false, env: {}) message = "#{COLOR_FG_BLUE}#{scenario}#{COLOR_RESET} > #{env.presence} #{cmd}" print message + (PARALLEL ? "\n" : "") @@ -107,9 +107,9 @@ def run_scenario(scenario) # run in parallel half the time env = if rand(2).zero? - { "PARALLEL_WORKERS" => "2", **adapter_config } + { "PARALLEL_WORKERS" => "2" } else - { "PARALLEL_WORKERS" => "1", **adapter_config } + { "PARALLEL_WORKERS" => "1" } end run_cmd(scenario, ["bin/rails test", *TEST_ARGS].join(" "), verbose: true, env:) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index 7312a062..cf7b7f7b 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -121,10 +121,13 @@ def path_for(database) private def configuration_hash_without_database - configuration_hash = db_config.configuration_hash.dup.merge(database: nil) + configuration_hash = db_config.configuration_hash.dup.merge( + database: nil, + database_tasks: false + ) ActiveRecord::DatabaseConfigurations::HashConfig.new( db_config.env_name, - db_config.name.to_s, + "_tmp_#{db_config.name}", configuration_hash ) end diff --git a/test/test_helper.rb b/test/test_helper.rb index eabbe931..c4969301 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -229,7 +229,7 @@ def run(...) end def all_configs - ActiveRecord::Base.configurations.configs_for(include_hidden: true) + ActiveRecord::Base.configurations.configs_for(env_name: "test", include_hidden: true) end def base_config From 3c055d5c2e20c02317976567e4e31677d13666a6 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 10:52:41 -0500 Subject: [PATCH 41/49] Ensure unit test only runs for sqlite --- test/unit/tenant_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index c5c42a4d..07cff6ef 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -650,7 +650,7 @@ end describe ".create_tenant" do - with_scenario(:primary_named_db, :primary_record) do + with_scenario("sqlite/primary_named_db", :primary_record) do describe "failed migration because database is readonly" do setup do db_config["test"]["tenanted"]["readonly"] = true From eabf8c834eb8de81551fe9aa121216acf6efc795 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 11:02:13 -0500 Subject: [PATCH 42/49] Fix mismatch in system tests for action cable setup This ensures the adapter actually broadcasts over the WebSocket connection within the same process, which is what system tests need. --- test/smarty/config/cable.yml | 2 +- test/smarty/test/application_system_test_case.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/test/smarty/config/cable.yml b/test/smarty/config/cable.yml index b9adc5aa..552c5114 100644 --- a/test/smarty/config/cable.yml +++ b/test/smarty/config/cable.yml @@ -6,7 +6,7 @@ development: adapter: async test: - adapter: test + adapter: async production: adapter: solid_cable diff --git a/test/smarty/test/application_system_test_case.rb b/test/smarty/test/application_system_test_case.rb index e9e3db2a..93e5026f 100644 --- a/test/smarty/test/application_system_test_case.rb +++ b/test/smarty/test/application_system_test_case.rb @@ -7,4 +7,9 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driver_option.add_argument("--disable-dev-shm-usage") driver_option.add_argument("--no-sandbox") end + + def setup + ActionCable.server.config.cable = { "adapter" => "async" } + super + end end From ed0539c12eac5e0d60caefa3456974b1dba4adf4 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 11:23:14 -0500 Subject: [PATCH 43/49] Try explicitly busting the cache --- test/smarty/app/models/note.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/smarty/app/models/note.rb b/test/smarty/app/models/note.rb index f6050750..8bafb7cc 100644 --- a/test/smarty/app/models/note.rb +++ b/test/smarty/app/models/note.rb @@ -1,7 +1,7 @@ class Note < ApplicationRecord has_one_attached :image - after_update_commit :broadcast_replace + after_update_commit :expire_cache_and_broadcast after_update_commit do NoteCheerioJob.perform_later(self) if needs_cheerio? @@ -10,4 +10,10 @@ class Note < ApplicationRecord def needs_cheerio? !body.include?("Cheerio!") end + + private + def expire_cache_and_broadcast + Rails.cache.delete(self) + broadcast_replace + end end From 76c2acf1c0c4f09319d9c33774027fd636b3afba Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 11:32:14 -0500 Subject: [PATCH 44/49] Updo the cache busting and add some debug info --- test/integration/test/turbo_broadcast_test.rb | 54 +++++++++++++++++++ test/smarty/app/models/note.rb | 8 +-- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/test/integration/test/turbo_broadcast_test.rb b/test/integration/test/turbo_broadcast_test.rb index cf7f31af..1285f5ae 100644 --- a/test/integration/test/turbo_broadcast_test.rb +++ b/test/integration/test/turbo_broadcast_test.rb @@ -13,7 +13,18 @@ class TestTurboBroadcast < ApplicationSystemTestCase visit note_url(note1) assert_text("note 1 version 1") + # Debug: Check Turbo Stream connection state + debug_turbo_stream_connection("after visit, before update") + note1.update!(body: "note 1 version 2") + + # Debug: Check state after update + debug_turbo_stream_connection("after update") + + # Give it a moment to process + sleep 0.1 if ENV["CI"] + debug_turbo_stream_connection("after sleep") + assert_text("note 1 version 2") ApplicationRecord.with_tenant(tenant2) do @@ -22,4 +33,47 @@ class TestTurboBroadcast < ApplicationSystemTestCase assert_no_text("note 2 version 2") assert_text("note 1 version 2") end + + private + def debug_turbo_stream_connection(label) + return unless ENV["CI"] + + puts "\n=== DEBUG: #{label} ===" + + # Check if turbo-cable-stream-source element exists + has_stream = page.has_css?("turbo-cable-stream-source", visible: false) + puts "Has turbo-cable-stream-source: #{has_stream}" + + if has_stream + # Check connection state via JavaScript + connected = page.evaluate_script(<<~JS) + const streamSource = document.querySelector('turbo-cable-stream-source'); + if (streamSource) { + const subscription = streamSource.subscription; + console.log('Stream source found:', streamSource); + console.log('Subscription:', subscription); + console.log('Consumer state:', streamSource.consumer?.connection?.isActive()); + { + hasElement: true, + hasConnectedAttr: streamSource.hasAttribute('connected'), + hasSubscription: !!subscription, + consumerState: streamSource.consumer?.connection?.getState?.() || 'unknown' + } + } else { + { hasElement: false } + } + JS + puts "Connection details: #{connected.inspect}" + + # Check the actual HTML + stream_html = page.find("turbo-cable-stream-source", visible: false)[:outerHTML] rescue "not found" + puts "Stream element HTML: #{stream_html}" + end + + # Check current body content + body_text = page.find("div", text: /Body:/).text rescue "not found" + puts "Current body on page: #{body_text}" + + puts "===================\n" + end end diff --git a/test/smarty/app/models/note.rb b/test/smarty/app/models/note.rb index 8bafb7cc..f6050750 100644 --- a/test/smarty/app/models/note.rb +++ b/test/smarty/app/models/note.rb @@ -1,7 +1,7 @@ class Note < ApplicationRecord has_one_attached :image - after_update_commit :expire_cache_and_broadcast + after_update_commit :broadcast_replace after_update_commit do NoteCheerioJob.perform_later(self) if needs_cheerio? @@ -10,10 +10,4 @@ class Note < ApplicationRecord def needs_cheerio? !body.include?("Cheerio!") end - - private - def expire_cache_and_broadcast - Rails.cache.delete(self) - broadcast_replace - end end From 41e1f2386aa23bffc6c2590b3b084dbcbd569f0d Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 11:39:01 -0500 Subject: [PATCH 45/49] Try this --- test/integration/test/turbo_broadcast_test.rb | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/test/integration/test/turbo_broadcast_test.rb b/test/integration/test/turbo_broadcast_test.rb index 1285f5ae..c8d3416b 100644 --- a/test/integration/test/turbo_broadcast_test.rb +++ b/test/integration/test/turbo_broadcast_test.rb @@ -13,15 +13,12 @@ class TestTurboBroadcast < ApplicationSystemTestCase visit note_url(note1) assert_text("note 1 version 1") - # Debug: Check Turbo Stream connection state debug_turbo_stream_connection("after visit, before update") note1.update!(body: "note 1 version 2") - # Debug: Check state after update debug_turbo_stream_connection("after update") - # Give it a moment to process sleep 0.1 if ENV["CI"] debug_turbo_stream_connection("after sleep") @@ -40,28 +37,33 @@ def debug_turbo_stream_connection(label) puts "\n=== DEBUG: #{label} ===" - # Check if turbo-cable-stream-source element exists has_stream = page.has_css?("turbo-cable-stream-source", visible: false) puts "Has turbo-cable-stream-source: #{has_stream}" if has_stream - # Check connection state via JavaScript + # ES5-compatible JavaScript connected = page.evaluate_script(<<~JS) - const streamSource = document.querySelector('turbo-cable-stream-source'); - if (streamSource) { - const subscription = streamSource.subscription; - console.log('Stream source found:', streamSource); - console.log('Subscription:', subscription); - console.log('Consumer state:', streamSource.consumer?.connection?.isActive()); - { - hasElement: true, - hasConnectedAttr: streamSource.hasAttribute('connected'), - hasSubscription: !!subscription, - consumerState: streamSource.consumer?.connection?.getState?.() || 'unknown' + (function() { + var streamSource = document.querySelector('turbo-cable-stream-source'); + if (streamSource) { + var subscription = streamSource.subscription; + var consumer = streamSource.consumer; + var connection = consumer ? consumer.connection : null; + var connectionState = connection ? (connection.getState ? connection.getState() : 'unknown') : 'no-connection'; + var isActive = connection ? (connection.isActive ? connection.isActive() : false) : false; + + return { + hasElement: true, + hasConnectedAttr: streamSource.hasAttribute('connected'), + hasSubscription: !!subscription, + consumerState: connectionState, + consumerIsActive: isActive, + signedStreamName: streamSource.getAttribute('signed-stream-name') || 'none' + }; + } else { + return { hasElement: false }; } - } else { - { hasElement: false } - } + })(); JS puts "Connection details: #{connected.inspect}" @@ -71,9 +73,14 @@ def debug_turbo_stream_connection(label) end # Check current body content - body_text = page.find("div", text: /Body:/).text rescue "not found" + body_elem = page.all("div", text: /Body:/).first + body_text = body_elem ? body_elem.text : "not found" puts "Current body on page: #{body_text}" + # Check the note body specifically + note_body = page.all("div").find { |div| div.text.match?(/note \d+ version \d+/) } + puts "Note body element: #{note_body ? note_body.text : 'not found'}" + puts "===================\n" end end From 745df5dae5b613b38420db0076575348ad77b5f5 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 11:40:57 -0500 Subject: [PATCH 46/49] Try to fix the debug methods --- test/integration/test/turbo_broadcast_test.rb | 53 ++++++------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/test/integration/test/turbo_broadcast_test.rb b/test/integration/test/turbo_broadcast_test.rb index c8d3416b..1502bfc0 100644 --- a/test/integration/test/turbo_broadcast_test.rb +++ b/test/integration/test/turbo_broadcast_test.rb @@ -41,45 +41,26 @@ def debug_turbo_stream_connection(label) puts "Has turbo-cable-stream-source: #{has_stream}" if has_stream - # ES5-compatible JavaScript - connected = page.evaluate_script(<<~JS) - (function() { - var streamSource = document.querySelector('turbo-cable-stream-source'); - if (streamSource) { - var subscription = streamSource.subscription; - var consumer = streamSource.consumer; - var connection = consumer ? consumer.connection : null; - var connectionState = connection ? (connection.getState ? connection.getState() : 'unknown') : 'no-connection'; - var isActive = connection ? (connection.isActive ? connection.isActive() : false) : false; - - return { - hasElement: true, - hasConnectedAttr: streamSource.hasAttribute('connected'), - hasSubscription: !!subscription, - consumerState: connectionState, - consumerIsActive: isActive, - signedStreamName: streamSource.getAttribute('signed-stream-name') || 'none' - }; - } else { - return { hasElement: false }; - } - })(); - JS - puts "Connection details: #{connected.inspect}" - - # Check the actual HTML + # Simple checks without complex JavaScript + has_connected_attr = page.has_css?("turbo-cable-stream-source[connected]", visible: false) + puts "Has 'connected' attribute: #{has_connected_attr}" + + # Get the HTML of the element stream_html = page.find("turbo-cable-stream-source", visible: false)[:outerHTML] rescue "not found" - puts "Stream element HTML: #{stream_html}" - end + puts "Stream element: #{stream_html}" - # Check current body content - body_elem = page.all("div", text: /Body:/).first - body_text = body_elem ? body_elem.text : "not found" - puts "Current body on page: #{body_text}" + # Check if subscription exists - simpler approach + has_subscription = page.evaluate_script("!!document.querySelector('turbo-cable-stream-source').subscription") rescue false + puts "Has subscription object: #{has_subscription}" + end - # Check the note body specifically - note_body = page.all("div").find { |div| div.text.match?(/note \d+ version \d+/) } - puts "Note body element: #{note_body ? note_body.text : 'not found'}" + # Check current page content + current_body = page.text + if current_body =~ /note 1 version (\d+)/ + puts "Current note version shown: version #{$1}" + else + puts "Could not find note version in page" + end puts "===================\n" end From d238e6b2d27c1c46fe2d25c37245d79786a687a7 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 7 Nov 2025 11:47:33 -0500 Subject: [PATCH 47/49] Cleanup fix for test --- test/integration/test/turbo_broadcast_test.rb | 45 +++---------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/test/integration/test/turbo_broadcast_test.rb b/test/integration/test/turbo_broadcast_test.rb index 1502bfc0..8a3b772d 100644 --- a/test/integration/test/turbo_broadcast_test.rb +++ b/test/integration/test/turbo_broadcast_test.rb @@ -13,15 +13,9 @@ class TestTurboBroadcast < ApplicationSystemTestCase visit note_url(note1) assert_text("note 1 version 1") - debug_turbo_stream_connection("after visit, before update") + wait_for_turbo_stream_connected note1.update!(body: "note 1 version 2") - - debug_turbo_stream_connection("after update") - - sleep 0.1 if ENV["CI"] - debug_turbo_stream_connection("after sleep") - assert_text("note 1 version 2") ApplicationRecord.with_tenant(tenant2) do @@ -32,36 +26,11 @@ class TestTurboBroadcast < ApplicationSystemTestCase end private - def debug_turbo_stream_connection(label) - return unless ENV["CI"] - - puts "\n=== DEBUG: #{label} ===" - - has_stream = page.has_css?("turbo-cable-stream-source", visible: false) - puts "Has turbo-cable-stream-source: #{has_stream}" - - if has_stream - # Simple checks without complex JavaScript - has_connected_attr = page.has_css?("turbo-cable-stream-source[connected]", visible: false) - puts "Has 'connected' attribute: #{has_connected_attr}" - - # Get the HTML of the element - stream_html = page.find("turbo-cable-stream-source", visible: false)[:outerHTML] rescue "not found" - puts "Stream element: #{stream_html}" - - # Check if subscription exists - simpler approach - has_subscription = page.evaluate_script("!!document.querySelector('turbo-cable-stream-source').subscription") rescue false - puts "Has subscription object: #{has_subscription}" - end - - # Check current page content - current_body = page.text - if current_body =~ /note 1 version (\d+)/ - puts "Current note version shown: version #{$1}" - else - puts "Could not find note version in page" - end - - puts "===================\n" + # Wait for the Turbo Stream WebSocket connection to establish before broadcasting updates. + # Without this, broadcasts can be sent before the browser is subscribed, causing them to be lost. + def wait_for_turbo_stream_connected + assert_selector("turbo-cable-stream-source[connected]", visible: false, wait: 5) + rescue Capybara::ElementNotFound + sleep 0.1 end end From d8d75c97cb16dbc6e7b9c88de19fa38362c68c1a Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 14 Nov 2025 11:57:01 -0500 Subject: [PATCH 48/49] Refactor temp_connection name --- .../tenanted/database_adapters/mysql.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index cf7b7f7b..a0723716 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -16,7 +16,7 @@ def tenant_databases scanner = Regexp.new("^" + Regexp.escape(scanner_pattern).gsub(Regexp.escape("(.+)"), "(.+)") + "$") begin - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + with_anonymous_connection do |connection| result = connection.execute("SHOW DATABASES LIKE '#{like_pattern}'") result.filter_map do |row| @@ -64,7 +64,7 @@ def validate_tenant_name(tenant_name) end def create_database - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + with_anonymous_connection do |connection| create_options = Hash.new.tap do |options| options[:charset] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.include?(:encoding) options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.include?(:collation) @@ -75,13 +75,13 @@ def create_database end def drop_database - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + with_anonymous_connection do |connection| connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(database_path)}") end end def database_exist? - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database) do |connection| + with_anonymous_connection do |connection| result = connection.execute("SHOW DATABASES LIKE '#{database_path}'") result.any? end @@ -120,6 +120,10 @@ def path_for(database) end private + def with_anonymous_connection(&block) + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(configuration_hash_without_database, &block) + end + def configuration_hash_without_database configuration_hash = db_config.configuration_hash.dup.merge( database: nil, From 1f8804bd9947623012bf9cd9f3633152de66e931 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Fri, 21 Nov 2025 11:40:37 -0500 Subject: [PATCH 49/49] Remove dev containers to prefer a bin/setup script --- .devcontainer/Dockerfile | 15 ----- .devcontainer/devcontainer.json | 19 ------ .devcontainer/docker-compose.yml | 39 ------------ .github/workflows/ci.yml | 2 + bin/setup | 63 +++++++++++++++++++ test/scenarios/mysql/primary_db/database.yml | 8 +-- .../mysql/primary_named_db/database.yml | 8 +-- .../scenarios/mysql/secondary_db/database.yml | 8 +-- 8 files changed, 77 insertions(+), 85 deletions(-) delete mode 100644 .devcontainer/Dockerfile delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .devcontainer/docker-compose.yml create mode 100755 bin/setup diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 20d35f4d..00000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM ghcr.io/rails/devcontainer/images/ruby:3.3.5 - -USER root -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - libmariadb-dev-compat \ - chromium \ - chromium-driver \ - libnss3 \ - libfontconfig1 \ - && rm -rf /var/lib/apt/lists/* - -ENV CHROME_BIN=/usr/bin/chromium -ENV CHROMEDRIVER_PATH=/usr/bin/chromedriver -ENV SE_AVOID_STATS=true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index e545495a..00000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,19 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/ruby -{ - "name": "activerecord-tenanted", - "dockerComposeFile": [ - "docker-compose.yml" - ], - "service": "app", - "workspaceFolder": "/workspaces/activerecord-tenanted", - "features": { - "ghcr.io/devcontainers/features/docker-in-docker": { - "moby": false - }, - "ghcr.io/devcontainers/features/github-cli:1": { - "version": "latest" - }, - "ghcr.io/rails/devcontainer/features/mysql-client": {} - } -} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml deleted file mode 100644 index f75d2c31..00000000 --- a/.devcontainer/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - app: - build: - context: .. - dockerfile: .devcontainer/Dockerfile - command: sleep infinity - volumes: - - ..:/workspaces/activerecord-tenanted:cached - environment: - MYSQL_HOST: mysql - MYSQL_PORT: "3306" - MYSQL_USERNAME: rails - MYSQL_PASSWORD: rails - MYSQL_ROOT_PASSWORD: devcontainer - MYSQL_DEVELOPMENT_DB: activerecord_tenanted_development - MYSQL_TEST_DB: activerecord_tenanted_test - MYSQL_INIT_TIMEOUT: "120" - depends_on: - mysql: - condition: service_healthy - - mysql: - image: mysql:8.0 - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: devcontainer - MYSQL_USER: rails - MYSQL_PASSWORD: rails - MYSQL_DATABASE: activerecord_tenanted_development - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 5s - timeout: 10s - retries: 10 - volumes: - - mysql-data:/var/lib/mysql - -volumes: - mysql-data: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 703e7ae3..e4ff2897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: - run: bin/test-unit env: MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: "3306" integration: runs-on: ubuntu-latest @@ -75,3 +76,4 @@ jobs: - run: bin/test-integration env: MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: "3306" diff --git a/bin/setup b/bin/setup new file mode 100755 index 00000000..1286212b --- /dev/null +++ b/bin/setup @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -e + +CONTAINER_NAME="${CONTAINER_NAME:-activerecord-tenanted-mysql}" +MYSQL_IMAGE="mysql:latest" +MYSQL_ROOT_PASSWORD="devcontainer" +MYSQL_PORT="${MYSQL_PORT:-3307}" + +echo "==> Checking Docker installation..." +if ! command -v docker &> /dev/null; then + echo "ERROR: Docker is not installed. Please install Docker and try again." + exit 1 +fi + +if ! docker info &> /dev/null; then + echo "ERROR: Docker daemon is not running. Please start Docker and try again." + exit 1 +fi + +echo "==> Setting up MySQL container..." +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo " Stopping and removing existing container..." + docker stop "$CONTAINER_NAME" > /dev/null 2>&1 || true + docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true +fi + +echo "==> Creating and starting MySQL container..." +docker run -d \ + --name="$CONTAINER_NAME" \ + --restart unless-stopped \ + -p "127.0.0.1:${MYSQL_PORT}:3306" \ + -e MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" \ + "$MYSQL_IMAGE" + +echo "==> Waiting for MySQL to be ready..." +MAX_TRIES=30 +TRIES=0 +while [ $TRIES -lt $MAX_TRIES ]; do + if docker exec "$CONTAINER_NAME" mysqladmin ping -h localhost --silent &> /dev/null; then + echo " MySQL is ready!" + break + fi + TRIES=$((TRIES + 1)) + if [ $TRIES -eq $MAX_TRIES ]; then + echo "ERROR: MySQL failed to start within expected time" + exit 1 + fi + echo -n "." + sleep 2 +done + +echo "==> Verifying database connection..." +docker exec "$CONTAINER_NAME" mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT 1;" > /dev/null 2>&1 +echo " Database ready!" + +echo "==> Running bundle install..." +bundle check > /dev/null 2>&1 || bundle install + +echo "" +echo "==> Setup complete!" +echo "" +echo "MySQL is running on 127.0.0.1:${MYSQL_PORT}" +echo "" diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index 267da0b3..faed9e0d 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -3,10 +3,10 @@ default: &default encoding: utf8mb4 pool: 20 max_connections: 20 - username: root - password: devcontainer - host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> - port: 3306 + username: <%= ENV.fetch('MYSQL_USERNAME', 'root') %> + password: <%= ENV.fetch('MYSQL_PASSWORD', 'devcontainer') %> + host: <%= ENV.fetch('MYSQL_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('MYSQL_PORT', '3307') %> sslmode: disabled test: diff --git a/test/scenarios/mysql/primary_named_db/database.yml b/test/scenarios/mysql/primary_named_db/database.yml index 38cf22c0..81abc69f 100644 --- a/test/scenarios/mysql/primary_named_db/database.yml +++ b/test/scenarios/mysql/primary_named_db/database.yml @@ -3,10 +3,10 @@ default: &default encoding: utf8mb4 pool: 20 max_connections: 20 - username: root - password: devcontainer - host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> - port: 3306 + username: <%= ENV.fetch('MYSQL_USERNAME', 'root') %> + password: <%= ENV.fetch('MYSQL_PASSWORD', 'devcontainer') %> + host: <%= ENV.fetch('MYSQL_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('MYSQL_PORT', '3307') %> sslmode: disabled test: diff --git a/test/scenarios/mysql/secondary_db/database.yml b/test/scenarios/mysql/secondary_db/database.yml index a0e6abfd..87fa9800 100644 --- a/test/scenarios/mysql/secondary_db/database.yml +++ b/test/scenarios/mysql/secondary_db/database.yml @@ -3,10 +3,10 @@ default: &default encoding: utf8mb4 pool: 20 max_connections: 20 - username: root - password: devcontainer - host: <%= ENV.fetch('MYSQL_HOST', 'mysql') %> - port: 3306 + username: <%= ENV.fetch('MYSQL_USERNAME', 'root') %> + password: <%= ENV.fetch('MYSQL_PASSWORD', 'devcontainer') %> + host: <%= ENV.fetch('MYSQL_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('MYSQL_PORT', '3307') %> sslmode: disabled test: