From 98169fdd4213a350c44cb4652acacf70e4c45563 Mon Sep 17 00:00:00 2001 From: Andrew Markle Date: Wed, 29 Oct 2025 10:19:01 -0400 Subject: [PATCH 01/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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: From 7b09a4655d1aea6dd9c51d71b50c9a23481593f1 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Thu, 18 Dec 2025 02:44:23 -0600 Subject: [PATCH 50/64] Add PostgreSQL Support --- .github/workflows/ci.yml | 32 +++ Gemfile | 1 + Gemfile.lock | 5 + README.md | 2 +- bin/setup | 63 ++++- bin/test-integration | 2 + lib/active_record/tenanted.rb | 1 + .../tenanted/database_adapter.rb | 1 + .../tenanted/database_adapters/postgresql.rb | 253 ++++++++++++++++++ .../database_configurations/base_config.rb | 17 +- .../database_configurations/tenant_config.rb | 10 +- lib/active_record/tenanted/patches.rb | 29 ++ lib/active_record/tenanted/railtie.rb | 5 + test/scenarios/mysql/primary_db/database.yml | 2 +- .../mysql/primary_named_db/database.yml | 2 +- .../scenarios/mysql/secondary_db/database.yml | 2 +- .../20250213005959_add_age_to_users.rb | 5 + .../postgresql/20250830152220_create_posts.rb | 10 + ...0250830170325_add_announcement_to_users.rb | 5 + ...0250830175957_add_announceable_to_users.rb | 5 + .../postgresql/primary_db/database.yml | 21 ++ .../postgresql/primary_db/primary_record.rb | 22 ++ .../postgresql/primary_db/secondary_record.rb | 22 ++ .../20250203191207_create_announcements.rb | 11 + .../postgresql/primary_db/subtenant_record.rb | 24 ++ .../20250203191115_create_users.rb | 11 + .../postgresql/primary_named_db/database.yml | 22 ++ .../primary_named_db/primary_record.rb | 22 ++ .../primary_named_db/secondary_record.rb | 22 ++ .../20250203191207_create_announcements.rb | 11 + .../20250203191115_create_users.rb | 11 + test/scenarios/postgresql/schema.rb | 19 ++ .../postgresql/secondary_db/database.yml | 22 ++ .../postgresql/secondary_db/primary_record.rb | 22 ++ .../secondary_db/secondary_record.rb | 22 ++ .../20250203191207_create_announcements.rb | 11 + .../20250203191115_create_users.rb | 11 + test/test_helper.rb | 2 + .../unit/database_adapters_postgresql_test.rb | 83 ++++++ 39 files changed, 828 insertions(+), 15 deletions(-) create mode 100644 lib/active_record/tenanted/database_adapters/postgresql.rb create mode 100644 test/scenarios/postgresql/20250213005959_add_age_to_users.rb create mode 100644 test/scenarios/postgresql/20250830152220_create_posts.rb create mode 100644 test/scenarios/postgresql/20250830170325_add_announcement_to_users.rb create mode 100644 test/scenarios/postgresql/20250830175957_add_announceable_to_users.rb create mode 100644 test/scenarios/postgresql/primary_db/database.yml create mode 100644 test/scenarios/postgresql/primary_db/primary_record.rb create mode 100644 test/scenarios/postgresql/primary_db/secondary_record.rb create mode 100644 test/scenarios/postgresql/primary_db/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/postgresql/primary_db/subtenant_record.rb create mode 100644 test/scenarios/postgresql/primary_db/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/scenarios/postgresql/primary_named_db/database.yml create mode 100644 test/scenarios/postgresql/primary_named_db/primary_record.rb create mode 100644 test/scenarios/postgresql/primary_named_db/secondary_record.rb create mode 100644 test/scenarios/postgresql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/postgresql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/scenarios/postgresql/schema.rb create mode 100644 test/scenarios/postgresql/secondary_db/database.yml create mode 100644 test/scenarios/postgresql/secondary_db/primary_record.rb create mode 100644 test/scenarios/postgresql/secondary_db/secondary_record.rb create mode 100644 test/scenarios/postgresql/secondary_db/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/postgresql/secondary_db/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/unit/database_adapters_postgresql_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4ff2897..42cc912f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,18 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=3 + postgres: + image: postgres:latest + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=3 steps: - uses: actions/checkout@v5 - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 @@ -51,6 +63,10 @@ jobs: env: MYSQL_HOST: 127.0.0.1 MYSQL_PORT: "3306" + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PORT: "5432" + POSTGRES_USERNAME: postgres + POSTGRES_PASSWORD: postgres integration: runs-on: ubuntu-latest @@ -67,6 +83,18 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=3 + postgres: + image: postgres:latest + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=3 steps: - uses: actions/checkout@v5 - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 @@ -77,3 +105,7 @@ jobs: env: MYSQL_HOST: 127.0.0.1 MYSQL_PORT: "3306" + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PORT: "5432" + POSTGRES_USERNAME: postgres + POSTGRES_PASSWORD: postgres diff --git a/Gemfile b/Gemfile index 55441bed..7f5b9880 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ group :development, :test do gem "debug", "1.11.0" gem "minitest-parallel_fork", "2.1.0", require: false gem "mysql2", "~> 0.5", require: false + gem "pg", "~> 1.1", require: false end group :rubocop do diff --git a/Gemfile.lock b/Gemfile.lock index c554c492..1bd26205 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -201,6 +201,10 @@ GEM parser (3.3.9.0) ast (~> 2.4.1) racc + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -352,6 +356,7 @@ DEPENDENCIES jbuilder minitest-parallel_fork (= 2.1.0) mysql2 (~> 0.5) + pg (~> 1.1) propshaft puma (>= 5.0) rails! diff --git a/README.md b/README.md index 2982a178..cc152c98 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Enable a Rails application to host multiple isolated tenants. > [!NOTE] -> Only the sqlite3 database adapter is fully supported right now. If you have a use case for tenanting one of the other databases supported by Rails, please reach out to the maintainers! +> Currently supported database adapters: SQLite3, MySQL (mysql2/trilogy), and PostgreSQL. If you have a use case for tenanting other databases supported by Rails, please reach out to the maintainers! ## Summary diff --git a/bin/setup b/bin/setup index 1286212b..9549dfaf 100755 --- a/bin/setup +++ b/bin/setup @@ -1,11 +1,16 @@ #!/usr/bin/env bash set -e -CONTAINER_NAME="${CONTAINER_NAME:-activerecord-tenanted-mysql}" +MYSQL_CONTAINER_NAME="${MYSQL_CONTAINER_NAME:-activerecord-tenanted-mysql}" MYSQL_IMAGE="mysql:latest" MYSQL_ROOT_PASSWORD="devcontainer" MYSQL_PORT="${MYSQL_PORT:-3307}" +POSTGRES_CONTAINER_NAME="${POSTGRES_CONTAINER_NAME:-activerecord-tenanted-postgres}" +POSTGRES_IMAGE="postgres:latest" +POSTGRES_PASSWORD="postgres" +POSTGRES_PORT="${POSTGRES_PORT:-5432}" + echo "==> Checking Docker installation..." if ! command -v docker &> /dev/null; then echo "ERROR: Docker is not installed. Please install Docker and try again." @@ -18,15 +23,15 @@ if ! docker info &> /dev/null; then fi echo "==> Setting up MySQL container..." -if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then +if docker ps -a --format '{{.Names}}' | grep -q "^${MYSQL_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 + docker stop "$MYSQL_CONTAINER_NAME" > /dev/null 2>&1 || true + docker rm "$MYSQL_CONTAINER_NAME" > /dev/null 2>&1 || true fi echo "==> Creating and starting MySQL container..." docker run -d \ - --name="$CONTAINER_NAME" \ + --name="$MYSQL_CONTAINER_NAME" \ --restart unless-stopped \ -p "127.0.0.1:${MYSQL_PORT}:3306" \ -e MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" \ @@ -36,7 +41,7 @@ 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 + if docker exec "$MYSQL_CONTAINER_NAME" mysqladmin ping -h localhost --silent &> /dev/null; then echo " MySQL is ready!" break fi @@ -49,9 +54,44 @@ while [ $TRIES -lt $MAX_TRIES ]; do 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 "==> Verifying MySQL database connection..." +docker exec "$MYSQL_CONTAINER_NAME" mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT 1;" > /dev/null 2>&1 +echo " MySQL database ready!" + +echo "==> Setting up PostgreSQL container..." +if docker ps -a --format '{{.Names}}' | grep -q "^${POSTGRES_CONTAINER_NAME}$"; then + echo " Stopping and removing existing container..." + docker stop "$POSTGRES_CONTAINER_NAME" > /dev/null 2>&1 || true + docker rm "$POSTGRES_CONTAINER_NAME" > /dev/null 2>&1 || true +fi + +echo "==> Creating and starting PostgreSQL container..." +docker run -d \ + --name="$POSTGRES_CONTAINER_NAME" \ + --restart unless-stopped \ + -p "127.0.0.1:${POSTGRES_PORT}:5432" \ + -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + "$POSTGRES_IMAGE" + +echo "==> Waiting for PostgreSQL to be ready..." +TRIES=0 +while [ $TRIES -lt $MAX_TRIES ]; do + if docker exec "$POSTGRES_CONTAINER_NAME" pg_isready -U postgres &> /dev/null; then + echo " PostgreSQL is ready!" + break + fi + TRIES=$((TRIES + 1)) + if [ $TRIES -eq $MAX_TRIES ]; then + echo "ERROR: PostgreSQL failed to start within expected time" + exit 1 + fi + echo -n "." + sleep 2 +done + +echo "==> Verifying PostgreSQL database connection..." +docker exec "$POSTGRES_CONTAINER_NAME" psql -U postgres -c "SELECT 1;" > /dev/null 2>&1 +echo " PostgreSQL database ready!" echo "==> Running bundle install..." bundle check > /dev/null 2>&1 || bundle install @@ -60,4 +100,9 @@ echo "" echo "==> Setup complete!" echo "" echo "MySQL is running on 127.0.0.1:${MYSQL_PORT}" +echo "PostgreSQL is running on 127.0.0.1:${POSTGRES_PORT}" +echo "" +echo "Connection details:" +echo " MySQL: mysql -h 127.0.0.1 -P ${MYSQL_PORT} -u root -p${MYSQL_ROOT_PASSWORD}" +echo " PostgreSQL: psql -h 127.0.0.1 -p ${POSTGRES_PORT} -U postgres" echo "" diff --git a/bin/test-integration b/bin/test-integration index 635445c3..25f964d1 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -93,6 +93,8 @@ def run_scenario(scenario) adapter_config = {"RAILS_ENV" => "test","ARTENANT_SCHEMA_DUMP" => "1"} if scenario[:adapter] == "mysql" adapter_config.merge!({ "MYSQL_UNIQUE_PREFIX" => db_prefix }) + elsif scenario[:adapter] == "postgresql" + adapter_config.merge!({ "POSTGRES_UNIQUE_PREFIX" => db_prefix }) end # Drop existing databases to ensure fresh setup diff --git a/lib/active_record/tenanted.rb b/lib/active_record/tenanted.rb index b5d3bef9..3d1767e0 100644 --- a/lib/active_record/tenanted.rb +++ b/lib/active_record/tenanted.rb @@ -7,6 +7,7 @@ loader.inflector.inflect( "sqlite" => "SQLite", "mysql" => "MySQL", + "postgresql" => "PostgreSQL", ) loader.setup diff --git a/lib/active_record/tenanted/database_adapter.rb b/lib/active_record/tenanted/database_adapter.rb index 99c540ec..8af5ad83 100644 --- a/lib/active_record/tenanted/database_adapter.rb +++ b/lib/active_record/tenanted/database_adapter.rb @@ -27,6 +27,7 @@ def new(db_config) register "sqlite3", "ActiveRecord::Tenanted::DatabaseAdapters::SQLite" register "trilogy", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" register "mysql2", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" + register "postgresql", "ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL" end end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql.rb b/lib/active_record/tenanted/database_adapters/postgresql.rb new file mode 100644 index 00000000..30e4aad4 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters # :nodoc: + # PostgreSQL adapter using schema-based multi-tenancy + # + # Instead of creating separate databases per tenant, this adapter creates + # separate schemas within a single PostgreSQL database. This is more efficient + # and aligns with PostgreSQL best practices. + # + # Configuration example: + # database: tenant_%%{tenant} # This becomes the schema name + # + # The adapter will: + # - Connect to a single base database + # - Create/use schemas like "tenant_foo", "tenant_bar", etc. + # - Set schema_search_path to isolate tenants + class PostgreSQL + attr_reader :db_config + + def initialize(db_config) + @db_config = db_config + end + + def tenant_databases + # Query for all schemas matching the pattern + schema_pattern = schema_name_for("%") + scanner_pattern = schema_name_for("(.+)") + scanner = Regexp.new("^" + Regexp.escape(scanner_pattern).gsub(Regexp.escape("(.+)"), "(.+)") + "$") + + begin + with_base_connection do |connection| + # PostgreSQL stores schemas in information_schema.schemata + result = connection.execute(<<~SQL) + SELECT schema_name#{' '} + FROM information_schema.schemata#{' '} + WHERE schema_name LIKE '#{connection.quote_string(schema_pattern)}' + AND schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY schema_name + SQL + + result.filter_map do |row| + schema_name = row["schema_name"] || row[0] + match = schema_name.match(scanner) + if match.nil? + Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from schema #{schema_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, PG::Error => e + Rails.logger.warn "Failed to list tenant schemas: #{e.message}" + [] + end + end + + def validate_tenant_name(tenant_name) + return if tenant_name == "%" || tenant_name == "(.+)" + + schema_name = schema_name_for(tenant_name.to_s) + + return if schema_name.include?("%{") || schema_name.include?("%}") + + # PostgreSQL identifier max length is 63 bytes + # We validate against >= 63 to be conservative and match existing test expectations + if schema_name.length >= 63 + raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name too long (max 62 characters to be safe): #{schema_name.inspect}" + end + + # Schema names can contain letters, numbers, underscores, dollar signs, and hyphens + # We're permissive because we always quote schema names with quote_table_name + if schema_name.match?(/[^a-z0-9_$-]/i) + raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name contains invalid characters (only letters, numbers, underscores, $, and hyphens are allowed): #{schema_name.inspect}" + end + + # PostgreSQL identifiers must start with a letter (a-z) or underscore when unquoted + # We enforce this even when quoted for consistency across adapters + unless schema_name.match?(/^[a-z_]/i) + raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name must start with a letter or underscore: #{schema_name.inspect}" + end + end + + def create_database + # Create schema instead of database + schema = database_path + + with_base_connection do |connection| + quoted_schema = connection.quote_table_name(schema) + # Create the schema (our patch makes this idempotent with IF NOT EXISTS) + connection.execute("CREATE SCHEMA IF NOT EXISTS #{quoted_schema}") + + # Grant usage permissions (optional but good practice) + # This ensures the schema can be used by the current user + username = db_config.configuration_hash[:username] || "postgres" + connection.execute("GRANT ALL ON SCHEMA #{quoted_schema} TO #{connection.quote_table_name(username)}") + end + end + + def drop_database + # Drop schema instead of database + schema = database_path + + with_base_connection do |connection| + # CASCADE ensures all objects in the schema are dropped + connection.execute("DROP SCHEMA IF EXISTS #{connection.quote_table_name(schema)} CASCADE") + end + end + + def database_exist? + # Check if schema exists + schema = database_path + + with_base_connection do |connection| + result = connection.execute(<<~SQL) + SELECT 1#{' '} + FROM information_schema.schemata#{' '} + WHERE schema_name = '#{connection.quote_string(schema)}' + SQL + result.any? + end + rescue ActiveRecord::NoDatabaseError, PG::Error + false + end + + def database_ready? + database_exist? + end + + def acquire_ready_lock(&block) + # No file-system locking needed for schemas + yield + end + + def ensure_database_directory_exists + # No directory needed for schemas + true + end + + def database_path + # Returns the schema name for this tenant + # For PostgreSQL with schema-based tenancy, we store the schema name separately + # because db_config.database is the base database name + db_config.configuration_hash[:tenant_schema] || db_config.database + end + + def test_workerize(db, test_worker_id) + # For schemas, we append the worker ID to the schema name + test_worker_suffix = "_#{test_worker_id}" + + if db.end_with?(test_worker_suffix) + db + else + db + test_worker_suffix + end + end + + def path_for(schema_name) + # For PostgreSQL schemas, path is just the schema name + schema_name + end + + private + def with_base_connection(&block) + # Connect to the base database (without tenant-specific schema) + # This allows us to create/drop/query schemas + + # Ensure the base database exists first + ensure_base_database_exists + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(base_db_config, &block) + end + + def ensure_base_database_exists + # Check if base database exists, create if not + base_db_name = extract_base_database_name + + # Connect to postgres maintenance database to check/create base database + maintenance_config = db_config.configuration_hash.dup.merge( + database: "postgres", + database_tasks: false + ) + maintenance_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "_maint_#{db_config.name}", + maintenance_config + ) + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(maintenance_db_config) do |connection| + result = connection.execute("SELECT 1 FROM pg_database WHERE datname = '#{connection.quote_string(base_db_name)}'") + unless result.any? + # Create base database if it doesn't exist + Rails.logger.info "Creating base PostgreSQL database: #{base_db_name}" + create_options = {} + create_options[:encoding] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.key?(:encoding) + create_options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.key?(:collation) + + connection.create_database(base_db_name, create_options) + end + end + rescue PG::Error => e + # Ignore if database already exists (race condition) + raise unless e.message.include?("already exists") + rescue StandardError => e + Rails.logger.error "Failed to ensure base database exists: #{e.class}: #{e.message}" + raise + end + + def base_db_config + # Create a config for the base database + # We extract the base database name from the pattern + base_db_name = extract_base_database_name + + configuration_hash = db_config.configuration_hash.dup.merge( + database: base_db_name, + database_tasks: false, + schema_search_path: "public" # Use public schema for admin operations + ) + + ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "_tmp_#{db_config.name}", + configuration_hash + ) + end + + def extract_base_database_name + # Extract base database name from the pattern + # For "test_%{tenant}" (after YAML loading) we need a consistent database name + # We'll use the pattern with "tenanted" as the suffix + # This gives us something like "test_tenanted" + db_config.database.gsub(/%\{tenant\}/, "tenanted") + end + + def schema_name_for(tenant_name) + # Generate schema name from tenant name using the database pattern + # For pattern like "tenant_%%{tenant}", this becomes "tenant_foo" + sprintf(db_config.database, tenant: tenant_name.to_s) + 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 608764a7..078c2b18 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -50,7 +50,22 @@ def new_tenant_config(tenant_name) config_name = "#{name}_#{tenant_name}" config_hash = configuration_hash.dup.tap do |hash| hash[:tenant] = tenant_name - hash[:database] = database_for(tenant_name) + + # For PostgreSQL, use schema-based multi-tenancy + if adapter == "postgresql" + # Keep the same database, but set schema_search_path + # The database pattern like "test_%{tenant}" becomes the schema name + schema_name = database_for(tenant_name) + hash[:schema_search_path] = schema_name + # Store the schema name for the adapter to use + hash[:tenant_schema] = schema_name + # Use a consistent base database name + hash[:database] = database.gsub(/%\{tenant\}/, "tenanted") + else + # For other adapters, use database-per-tenant + hash[:database] = database_for(tenant_name) + end + hash[:tenanted_config_name] = name new_host = host_for(tenant_name) hash[:host] = new_host if new_host diff --git a/lib/active_record/tenanted/database_configurations/tenant_config.rb b/lib/active_record/tenanted/database_configurations/tenant_config.rb index 93a4f835..c2704f34 100644 --- a/lib/active_record/tenanted/database_configurations/tenant_config.rb +++ b/lib/active_record/tenanted/database_configurations/tenant_config.rb @@ -22,7 +22,15 @@ def new_connection # Rails, and this gem's dependency has been bumped to require that version or later. config_adapter.ensure_database_directory_exists - super.tap { |connection| connection.tenant = tenant } + super.tap do |connection| + connection.tenant = tenant + + # For PostgreSQL with schema-based tenancy, set the schema search path + if adapter == "postgresql" && configuration_hash[:schema_search_path] + schema = configuration_hash[:schema_search_path] + connection.execute("SET search_path TO #{connection.quote_table_name(schema)}") + end + end end def tenanted_config_name diff --git a/lib/active_record/tenanted/patches.rb b/lib/active_record/tenanted/patches.rb index b5828e8e..3d8f80c8 100644 --- a/lib/active_record/tenanted/patches.rb +++ b/lib/active_record/tenanted/patches.rb @@ -45,6 +45,35 @@ def _default_attributes # :nodoc: end end end + + # Patch PostgreSQL adapter to make create_schema idempotent + # This is needed because our schema-per-tenant approach creates the schema + # before loading the schema dump, and the schema dump also tries to create it. + module PostgreSQLSchemaStatements + def create_schema(schema_name, options = {}) + quoted_name = quote_table_name(schema_name) + sql = +"CREATE SCHEMA IF NOT EXISTS #{quoted_name}" + + if options[:owner] + sql << " AUTHORIZATION #{quote_table_name(options[:owner])}" + end + + execute sql + end + + # Patch schema_search_path= to properly quote schema names with special characters + # PostgreSQL requires identifiers with special characters (like hyphens) to be quoted + def schema_search_path=(schema_csv) + if schema_csv.present? + schemas = schema_csv.split(",").map(&:strip) + quoted_schemas = schemas.map { |schema| quote_table_name(schema) } + execute("SET search_path TO #{quoted_schemas.join(', ')}", "SCHEMA") + else + execute("SET search_path TO DEFAULT", "SCHEMA") + end + @schema_search_path = schema_csv + end + end end end end diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index 423a3512..af5f0e96 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -98,6 +98,11 @@ class Railtie < ::Rails::Railtie prepend ActiveRecord::Tenanted::Patches::Attributes ActiveRecord::Tasks::DatabaseTasks.prepend ActiveRecord::Tenanted::Patches::DatabaseTasks end + + # Patch PostgreSQL adapter to make create_schema idempotent + ActiveSupport.on_load(:active_record_postgresqladapter) do + prepend ActiveRecord::Tenanted::Patches::PostgreSQLSchemaStatements + end end initializer "active_record_tenanted.active_job" do diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml index faed9e0d..1dde3001 100644 --- a/test/scenarios/mysql/primary_db/database.yml +++ b/test/scenarios/mysql/primary_db/database.yml @@ -7,7 +7,7 @@ default: &default password: <%= ENV.fetch('MYSQL_PASSWORD', 'devcontainer') %> host: <%= ENV.fetch('MYSQL_HOST', '127.0.0.1') %> port: <%= ENV.fetch('MYSQL_PORT', '3307') %> - sslmode: disabled + ssl_mode: disabled test: primary: diff --git a/test/scenarios/mysql/primary_named_db/database.yml b/test/scenarios/mysql/primary_named_db/database.yml index 81abc69f..c3526e6b 100644 --- a/test/scenarios/mysql/primary_named_db/database.yml +++ b/test/scenarios/mysql/primary_named_db/database.yml @@ -7,7 +7,7 @@ default: &default password: <%= ENV.fetch('MYSQL_PASSWORD', 'devcontainer') %> host: <%= ENV.fetch('MYSQL_HOST', '127.0.0.1') %> port: <%= ENV.fetch('MYSQL_PORT', '3307') %> - sslmode: disabled + ssl_mode: disabled test: tenanted: diff --git a/test/scenarios/mysql/secondary_db/database.yml b/test/scenarios/mysql/secondary_db/database.yml index 87fa9800..4468aebb 100644 --- a/test/scenarios/mysql/secondary_db/database.yml +++ b/test/scenarios/mysql/secondary_db/database.yml @@ -7,7 +7,7 @@ default: &default password: <%= ENV.fetch('MYSQL_PASSWORD', 'devcontainer') %> host: <%= ENV.fetch('MYSQL_HOST', '127.0.0.1') %> port: <%= ENV.fetch('MYSQL_PORT', '3307') %> - sslmode: disabled + ssl_mode: disabled test: shared: diff --git a/test/scenarios/postgresql/20250213005959_add_age_to_users.rb b/test/scenarios/postgresql/20250213005959_add_age_to_users.rb new file mode 100644 index 00000000..bfe140c0 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/20250830152220_create_posts.rb b/test/scenarios/postgresql/20250830152220_create_posts.rb new file mode 100644 index 00000000..7242786a --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/20250830170325_add_announcement_to_users.rb b/test/scenarios/postgresql/20250830170325_add_announcement_to_users.rb new file mode 100644 index 00000000..73353f9a --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/20250830175957_add_announceable_to_users.rb b/test/scenarios/postgresql/20250830175957_add_announceable_to_users.rb new file mode 100644 index 00000000..70da8289 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_db/database.yml b/test/scenarios/postgresql/primary_db/database.yml new file mode 100644 index 00000000..b2d4b62e --- /dev/null +++ b/test/scenarios/postgresql/primary_db/database.yml @@ -0,0 +1,21 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: 20 + max_connections: 20 + username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> + +test: + primary: + <<: *default + tenanted: true + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + shared: + <<: *default + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_shared + migrations_paths: "%{db_path}/shared_migrations" + diff --git a/test/scenarios/postgresql/primary_db/primary_record.rb b/test/scenarios/postgresql/primary_db/primary_record.rb new file mode 100644 index 00000000..f9ad6c58 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_db/secondary_record.rb b/test/scenarios/postgresql/primary_db/secondary_record.rb new file mode 100644 index 00000000..7fdd930a --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_db/subtenant_record.rb b/test/scenarios/postgresql/primary_db/subtenant_record.rb new file mode 100644 index 00000000..5f3a22ba --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_named_db/database.yml b/test/scenarios/postgresql/primary_named_db/database.yml new file mode 100644 index 00000000..e5bbd2a4 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db/database.yml @@ -0,0 +1,22 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: 20 + max_connections: 20 + username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> + +test: + tenanted: + <<: *default + tenanted: true + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + shared: + <<: *default + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_shared + migrations_paths: "%{db_path}/shared_migrations" + + diff --git a/test/scenarios/postgresql/primary_named_db/primary_record.rb b/test/scenarios/postgresql/primary_named_db/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_named_db/secondary_record.rb b/test/scenarios/postgresql/primary_named_db/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/schema.rb b/test/scenarios/postgresql/schema.rb new file mode 100644 index 00000000..74d3e220 --- /dev/null +++ b/test/scenarios/postgresql/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 diff --git a/test/scenarios/postgresql/secondary_db/database.yml b/test/scenarios/postgresql/secondary_db/database.yml new file mode 100644 index 00000000..b50d0f51 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db/database.yml @@ -0,0 +1,22 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: 20 + max_connections: 20 + username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> + +test: + shared: + <<: *default + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_shared + migrations_paths: "%{db_path}/shared_migrations" + tenanted: + <<: *default + tenanted: true + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + + diff --git a/test/scenarios/postgresql/secondary_db/primary_record.rb b/test/scenarios/postgresql/secondary_db/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/secondary_db/secondary_record.rb b/test/scenarios/postgresql/secondary_db/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/secondary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/secondary_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/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/postgresql/secondary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/secondary_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/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 diff --git a/test/test_helper.rb b/test/test_helper.rb index c4969301..286dc9ff 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -294,6 +294,8 @@ def drop_shared_databases def drop_tentant_databases base_config = all_configs.find { |c| c.configuration_hash[:tenanted] } + return unless base_config + tenants = base_config.tenants return if tenants.empty? diff --git a/test/unit/database_adapters_postgresql_test.rb b/test/unit/database_adapters_postgresql_test.rb new file mode 100644 index 00000000..8ffb628b --- /dev/null +++ b/test/unit/database_adapters_postgresql_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL do + let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL.new(Object.new) } + + describe "path_for" do + test "returns database name as-is" do + database = "myapp_production" + expected = "myapp_production" + assert_equal(expected, adapter.path_for(database)) + end + end + + describe "test_workerize" do + test "appends test worker id to database name" do + db = "myapp_test" + test_worker_id = 1 + expected = "myapp_test_1" + assert_equal(expected, adapter.test_workerize(db, test_worker_id)) + end + + test "does not double-suffix if already present" do + db = "myapp_test_1" + test_worker_id = 1 + expected = "myapp_test_1" + assert_equal(expected, adapter.test_workerize(db, test_worker_id)) + end + end + + describe "validate_tenant_name" do + let(:db_config) do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}" } + ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + end + let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL.new(db_config) } + + test "allows valid tenant names" do + assert_nothing_raised do + adapter.validate_tenant_name("tenant1") + adapter.validate_tenant_name("tenant_123") + adapter.validate_tenant_name("tenant$foo") + adapter.validate_tenant_name("tenant-name") # hyphens are allowed + end + end + + test "raises error for database names that are too long" do + # Max is 63 characters, so with "myapp_" prefix (7 chars), tenant name can be max 56 chars + long_name = "a" * 57 + error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do + adapter.validate_tenant_name(long_name) + end + assert_match(/too long/, error.message) + end + + test "raises error for database names with invalid characters" do + error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do + adapter.validate_tenant_name("tenant.name") # dots are not allowed + end + assert_match(/invalid characters/, error.message) + end + + test "raises error for schema names starting with a number" do + # Create a config where the pattern would result in a schema starting with a number + config_hash = { adapter: "postgresql", database: "%{tenant}_schema" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL.new(db_config) + + error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do + adapter.validate_tenant_name("1tenant") + end + assert_match(/must start with a letter or underscore/, error.message) + end + + test "allows special validation patterns" do + assert_nothing_raised do + adapter.validate_tenant_name("%") + adapter.validate_tenant_name("(.+)") + end + end + end +end From 6ea0ec308cfe82bbf3845aa018b6d59a50eff265 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Thu, 18 Dec 2025 12:36:22 -0600 Subject: [PATCH 51/64] Change the test framework. Keeping in a seperate commit incase I need to drop this later. --- bin/test-integration | 20 +++++++++++++++++--- test/integration/test/test_helper.rb | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 test/integration/test/test_helper.rb diff --git a/bin/test-integration b/bin/test-integration index 25f964d1..898424ef 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -27,6 +27,13 @@ scenarios = Dir.glob("test/scenarios/*/*db/*record.rb").map do |path| { adapter: adapter, database: database, models: models } end +# Filter by adapter if ADAPTER environment variable is set +if ENV["ADAPTER"] + adapter_filter = ENV["ADAPTER"].downcase + scenarios.select! { |s| s[:adapter] == adapter_filter } + abort("No scenarios found for adapter: #{adapter_filter}") if scenarios.empty? +end + COLOR_FG_BLUE = "\e[0;34m" COLOR_RESET = "\e[0m" @@ -52,11 +59,17 @@ def run_scenario(scenario) 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_]/, "_") + # Use a very short prefix to avoid PostgreSQL's 63-character identifier limit + # Format: t (process ID is already unique enough for concurrent runs) + db_prefix = "t#{Process.pid}" # make a copy of the smarty app FileUtils.copy_entry(SMARTY_PATH, app_path) + # Set environment variables for ERB processing + ENV["POSTGRES_UNIQUE_PREFIX"] = db_prefix if scenario[:adapter] == "postgresql" + ENV["MYSQL_UNIQUE_PREFIX"] = db_prefix if scenario[:adapter] == "mysql" + # generate database file database_file = File.join(GEM_PATH, "test/scenarios/#{scenario[:adapter]}/#{scenario[:database]}/database.yml") erb_content = ERB.new(File.read(database_file)).result @@ -107,8 +120,9 @@ def run_scenario(scenario) File.exist?("db/#{prefix}schema.rb") || abort("Schema dump not generated") File.exist?("db/#{prefix}schema_cache.yml") || abort("Schema cache dump not generated") - # run in parallel half the time - env = if rand(2).zero? + # run in parallel half the time, but only when we're in parallel mode + # (otherwise it can cause deadlocks with PostgreSQL) + env = if PARALLEL && rand(2).zero? { "PARALLEL_WORKERS" => "2" } else { "PARALLEL_WORKERS" => "1" } diff --git a/test/integration/test/test_helper.rb b/test/integration/test/test_helper.rb new file mode 100644 index 00000000..afc1e1a6 --- /dev/null +++ b/test/integration/test/test_helper.rb @@ -0,0 +1,23 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + # Use PARALLEL_WORKERS env var if set, otherwise use number of processors + # Disable parallelization when workers is 1 to avoid test worker ID suffix issues + workers = if ENV["PARALLEL_WORKERS"] + worker_count = ENV["PARALLEL_WORKERS"].to_i + worker_count > 1 ? worker_count : 0 # 0 means disable parallelization + else + :number_of_processors + end + parallelize(workers: workers) unless workers == 0 + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end From ff3063bb2a76f1956bca129ba1c735aa13fc5de5 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Thu, 18 Dec 2025 12:39:31 -0600 Subject: [PATCH 52/64] Fix test-integration for postgresql by commiting the db transaction when creating a new schema --- .../tenanted/database_adapters/postgresql.rb | 10 +++++++--- test/unit/database_adapters_postgresql_test.rb | 13 +++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/postgresql.rb b/lib/active_record/tenanted/database_adapters/postgresql.rb index 30e4aad4..7fbb81f9 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql.rb @@ -73,9 +73,8 @@ def validate_tenant_name(tenant_name) return if schema_name.include?("%{") || schema_name.include?("%}") # PostgreSQL identifier max length is 63 bytes - # We validate against >= 63 to be conservative and match existing test expectations - if schema_name.length >= 63 - raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name too long (max 62 characters to be safe): #{schema_name.inspect}" + if schema_name.length > 63 + raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name too long (max 63 characters): #{schema_name.inspect}" end # Schema names can contain letters, numbers, underscores, dollar signs, and hyphens @@ -97,9 +96,14 @@ def create_database with_base_connection do |connection| quoted_schema = connection.quote_table_name(schema) + # Create the schema (our patch makes this idempotent with IF NOT EXISTS) connection.execute("CREATE SCHEMA IF NOT EXISTS #{quoted_schema}") + # Commit any pending transaction to ensure schema is visible to other connections + # with_temporary_connection may wrap DDL in a transaction + connection.commit_db_transaction if connection.transaction_open? + # Grant usage permissions (optional but good practice) # This ensures the schema can be used by the current user username = db_config.configuration_hash[:username] || "postgres" diff --git a/test/unit/database_adapters_postgresql_test.rb b/test/unit/database_adapters_postgresql_test.rb index 8ffb628b..d217f670 100644 --- a/test/unit/database_adapters_postgresql_test.rb +++ b/test/unit/database_adapters_postgresql_test.rb @@ -46,14 +46,23 @@ end test "raises error for database names that are too long" do - # Max is 63 characters, so with "myapp_" prefix (7 chars), tenant name can be max 56 chars - long_name = "a" * 57 + # Max is 63 characters, so with "myapp_" prefix (6 chars), tenant name can be max 57 chars + # Testing with 58 chars should fail (6 + 58 = 64 > 63) + long_name = "a" * 58 error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do adapter.validate_tenant_name(long_name) end assert_match(/too long/, error.message) end + test "allows database names at exactly 63 characters" do + # With "myapp_" prefix (6 chars), tenant name of 57 chars = exactly 63 total + max_length_name = "a" * 57 + assert_nothing_raised do + adapter.validate_tenant_name(max_length_name) + end + end + test "raises error for database names with invalid characters" do error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do adapter.validate_tenant_name("tenant.name") # dots are not allowed From 730356c8206d1a823495210bc701aaf83a5bf3aa Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Thu, 18 Dec 2025 17:12:26 -0600 Subject: [PATCH 53/64] Add postgresql_strategy option in database.yml to choose between schema (default) or database for configurating PostgreSQL --- GUIDE.md | 112 ++++++++ README.md | 2 +- lib/active_record/tenanted.rb | 3 + .../tenanted/database_adapter.rb | 2 +- .../tenanted/database_adapters/postgresql.rb | 260 +----------------- .../database_adapters/postgresql/base.rb | 107 +++++++ .../database_adapters/postgresql/database.rb | 163 +++++++++++ .../database_adapters/postgresql/factory.rb | 69 +++++ .../database_adapters/postgresql/schema.rb | 212 ++++++++++++++ .../database_configurations/base_config.rb | 22 +- .../database_configurations/tenant_config.rb | 4 +- .../postgresql/primary_db/database.yml | 1 + .../primary_db_database_strategy/database.yml | 22 ++ .../primary_record.rb | 22 ++ .../secondary_record.rb | 22 ++ .../20250203191207_create_announcements.rb | 11 + .../subtenant_record.rb | 24 ++ .../20250203191115_create_users.rb | 11 + .../postgresql/primary_named_db/database.yml | 1 + .../database.yml | 23 ++ .../primary_record.rb | 22 ++ .../secondary_record.rb | 22 ++ .../20250203191207_create_announcements.rb | 11 + .../20250203191115_create_users.rb | 11 + .../postgresql/secondary_db/database.yml | 1 + .../database.yml | 23 ++ .../primary_record.rb | 22 ++ .../secondary_record.rb | 22 ++ .../20250203191207_create_announcements.rb | 11 + .../20250203191115_create_users.rb | 11 + test/test_helper.rb | 16 +- .../database_adapters_postgresql_base_test.rb | 151 ++++++++++ ...abase_adapters_postgresql_database_test.rb | 69 +++++ ...tabase_adapters_postgresql_factory_test.rb | 84 ++++++ ...atabase_adapters_postgresql_schema_test.rb | 66 +++++ .../unit/database_adapters_postgresql_test.rb | 97 ++----- test/unit/tenant_test.rb | 4 +- 37 files changed, 1393 insertions(+), 343 deletions(-) create mode 100644 lib/active_record/tenanted/database_adapters/postgresql/base.rb create mode 100644 lib/active_record/tenanted/database_adapters/postgresql/database.rb create mode 100644 lib/active_record/tenanted/database_adapters/postgresql/factory.rb create mode 100644 lib/active_record/tenanted/database_adapters/postgresql/schema.rb create mode 100644 test/scenarios/postgresql/primary_db_database_strategy/database.yml create mode 100644 test/scenarios/postgresql/primary_db_database_strategy/primary_record.rb create mode 100644 test/scenarios/postgresql/primary_db_database_strategy/secondary_record.rb create mode 100644 test/scenarios/postgresql/primary_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/postgresql/primary_db_database_strategy/subtenant_record.rb create mode 100644 test/scenarios/postgresql/primary_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/scenarios/postgresql/primary_named_db_database_strategy/database.yml create mode 100644 test/scenarios/postgresql/primary_named_db_database_strategy/primary_record.rb create mode 100644 test/scenarios/postgresql/primary_named_db_database_strategy/secondary_record.rb create mode 100644 test/scenarios/postgresql/primary_named_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/postgresql/primary_named_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/scenarios/postgresql/secondary_db_database_strategy/database.yml create mode 100644 test/scenarios/postgresql/secondary_db_database_strategy/primary_record.rb create mode 100644 test/scenarios/postgresql/secondary_db_database_strategy/secondary_record.rb create mode 100644 test/scenarios/postgresql/secondary_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb create mode 100644 test/scenarios/postgresql/secondary_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/unit/database_adapters_postgresql_base_test.rb create mode 100644 test/unit/database_adapters_postgresql_database_test.rb create mode 100644 test/unit/database_adapters_postgresql_factory_test.rb create mode 100644 test/unit/database_adapters_postgresql_schema_test.rb diff --git a/GUIDE.md b/GUIDE.md index 76976156..9bd9a60f 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -272,6 +272,118 @@ class ApplicationRecord < ActiveRecord::Base end ``` +#### 2.2.1 PostgreSQL Multi-Tenancy Strategies + +PostgreSQL supports two isolation strategies, configurable via the `postgresql_strategy` option in `database.yml`. + +##### Schema-Based Multi-Tenancy (Default) + +Uses PostgreSQL schemas within a single database. This is the default strategy and is recommended for most use cases. + +**Configuration:** + +``` yaml +production: + primary: + adapter: postgresql + tenanted: true + # postgresql_strategy: schema # Optional - this is the default + database: myapp_%{tenant} + host: localhost +``` + +In this configuration: +- A single PostgreSQL database named `myapp_tenanted` is created +- Each tenant gets its own schema: `myapp_foo`, `myapp_bar`, etc. +- The `schema_search_path` is set automatically to isolate tenants +- All tables and data are stored within the tenant-specific schema + +**Advantages:** +- **Resource Efficient**: Single database process serves all tenants +- **Lower Memory Usage**: Shared buffer cache across tenants +- **Simpler Backup**: One database to backup/restore +- **Better for Scale**: Supports thousands of tenants efficiently +- **PostgreSQL Best Practice**: Aligns with PostgreSQL's schema design + +**Considerations:** +- Connection limits are shared across all tenants +- Schema-level isolation (not database-level) +- All tenants must use the same PostgreSQL version/settings + +##### Database-Based Multi-Tenancy + +Creates separate PostgreSQL databases for each tenant. Similar to how MySQL and SQLite work in this gem. + +**Configuration:** + +``` yaml +production: + primary: + adapter: postgresql + tenanted: true + postgresql_strategy: database # Explicitly use database-per-tenant + database: myapp_%{tenant} + host: localhost +``` + +In this configuration: +- Each tenant gets its own PostgreSQL database: `myapp_foo`, `myapp_bar`, etc. +- Each database has independent schemas, users, and settings +- Complete isolation between tenants at the database level + +**Advantages:** +- **Stronger Isolation**: Complete database-level separation +- **Independent Configuration**: Each tenant can have different settings +- **Easier Data Export**: Simple to dump/restore individual tenants +- **Familiar Pattern**: Consistent with MySQL/SQLite behavior + +**Considerations:** +- Higher resource usage (more database processes) +- Higher memory usage (separate buffer cache per database) +- More complex backup/restore operations +- PostgreSQL may have limits on number of databases +- Not recommended for hundreds of tenants + +##### Performance Comparison + +| Metric | Schema Strategy | Database Strategy | +|--------|----------------|-------------------| +| **Connection Overhead** | Low (single DB) | Medium (multiple DBs) | +| **Memory Usage** | Low (shared cache) | High (cache per DB) | +| **Query Performance** | Excellent | Excellent | +| **Tenant Isolation** | Schema-level | Database-level | +| **Backup/Restore** | Simple (one DB) | Complex (many DBs) | +| **Tenant Limit** | Thousands | Hundreds | +| **Resource Efficiency** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | +| **Data Isolation** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | + +##### Choosing a Strategy + +**Use Schema Strategy (default) when:** +- You have many tenants (dozens to thousands) +- Resource efficiency is important +- You're following PostgreSQL best practices +- Tenants share the same configuration needs +- You want simpler operations (backup, monitoring, etc.) + +**Use Database Strategy when:** +- You have few tenants (less than 100) +- You need database-level isolation for compliance +- Each tenant needs different database settings +- You need to easily export individual tenant databases +- You want consistency with MySQL/SQLite behavior + +##### Migration Between Strategies + +To change strategies, you'll need to: + +1. Export data from existing tenants +2. Update `database.yml` with new `postgresql_strategy` +3. Create new tenant databases/schemas +4. Import data into new structure + +**Note:** There is no automated migration tool. Plan strategy choice carefully before production deployment. + ### 2.3 Configuring `max_connection_pools` By default, Active Record Tenanted will cap the number of tenanted connection pools to 50. Setting a limit on the number of "live" connection pools at any one time provides control over the number of file descriptors used for database connections. For SQLite databases, it's also an important control on the amount of memory used. diff --git a/README.md b/README.md index cc152c98..5758e057 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Enable a Rails application to host multiple isolated tenants. > [!NOTE] -> Currently supported database adapters: SQLite3, MySQL (mysql2/trilogy), and PostgreSQL. If you have a use case for tenanting other databases supported by Rails, please reach out to the maintainers! +> Currently supported database adapters: SQLite3, MySQL (mysql2/trilogy), and PostgreSQL. PostgreSQL supports both schema-based (default) and database-based multi-tenancy strategies. If you have a use case for tenanting other databases supported by Rails, please reach out to the maintainers! ## Summary diff --git a/lib/active_record/tenanted.rb b/lib/active_record/tenanted.rb index 3d1767e0..c8b526a9 100644 --- a/lib/active_record/tenanted.rb +++ b/lib/active_record/tenanted.rb @@ -43,6 +43,9 @@ class IntegrationNotConfiguredError < Error; end # Raised when an unsupported database adapter is used. class UnsupportedDatabaseError < Error; end + # Raised when database configuration is invalid or incompatible. + class ConfigurationError < Error; end + # Return the constantized connection class configured in `config.active_record_tenanted.connection_class`, # or nil if none is configured. def self.connection_class diff --git a/lib/active_record/tenanted/database_adapter.rb b/lib/active_record/tenanted/database_adapter.rb index 8af5ad83..c7d878d0 100644 --- a/lib/active_record/tenanted/database_adapter.rb +++ b/lib/active_record/tenanted/database_adapter.rb @@ -27,7 +27,7 @@ def new(db_config) register "sqlite3", "ActiveRecord::Tenanted::DatabaseAdapters::SQLite" register "trilogy", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" register "mysql2", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" - register "postgresql", "ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL" + register "postgresql", "ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory" end end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql.rb b/lib/active_record/tenanted/database_adapters/postgresql.rb index 7fbb81f9..a6dd7832 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql.rb @@ -1,256 +1,22 @@ # frozen_string_literal: true +require_relative "postgresql/base" +require_relative "postgresql/schema" +require_relative "postgresql/database" +require_relative "postgresql/factory" + module ActiveRecord module Tenanted - module DatabaseAdapters # :nodoc: - # PostgreSQL adapter using schema-based multi-tenancy - # - # Instead of creating separate databases per tenant, this adapter creates - # separate schemas within a single PostgreSQL database. This is more efficient - # and aligns with PostgreSQL best practices. + module DatabaseAdapters + # PostgreSQL adapter support for multi-tenancy # - # Configuration example: - # database: tenant_%%{tenant} # This becomes the schema name + # Supports two strategies: + # - Schema-based (default): Multiple schemas within a single database + # - Database-based: Separate databases per tenant # - # The adapter will: - # - Connect to a single base database - # - Create/use schemas like "tenant_foo", "tenant_bar", etc. - # - Set schema_search_path to isolate tenants - class PostgreSQL - attr_reader :db_config - - def initialize(db_config) - @db_config = db_config - end - - def tenant_databases - # Query for all schemas matching the pattern - schema_pattern = schema_name_for("%") - scanner_pattern = schema_name_for("(.+)") - scanner = Regexp.new("^" + Regexp.escape(scanner_pattern).gsub(Regexp.escape("(.+)"), "(.+)") + "$") - - begin - with_base_connection do |connection| - # PostgreSQL stores schemas in information_schema.schemata - result = connection.execute(<<~SQL) - SELECT schema_name#{' '} - FROM information_schema.schemata#{' '} - WHERE schema_name LIKE '#{connection.quote_string(schema_pattern)}' - AND schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') - ORDER BY schema_name - SQL - - result.filter_map do |row| - schema_name = row["schema_name"] || row[0] - match = schema_name.match(scanner) - if match.nil? - Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from schema #{schema_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, PG::Error => e - Rails.logger.warn "Failed to list tenant schemas: #{e.message}" - [] - end - end - - def validate_tenant_name(tenant_name) - return if tenant_name == "%" || tenant_name == "(.+)" - - schema_name = schema_name_for(tenant_name.to_s) - - return if schema_name.include?("%{") || schema_name.include?("%}") - - # PostgreSQL identifier max length is 63 bytes - if schema_name.length > 63 - raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name too long (max 63 characters): #{schema_name.inspect}" - end - - # Schema names can contain letters, numbers, underscores, dollar signs, and hyphens - # We're permissive because we always quote schema names with quote_table_name - if schema_name.match?(/[^a-z0-9_$-]/i) - raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name contains invalid characters (only letters, numbers, underscores, $, and hyphens are allowed): #{schema_name.inspect}" - end - - # PostgreSQL identifiers must start with a letter (a-z) or underscore when unquoted - # We enforce this even when quoted for consistency across adapters - unless schema_name.match?(/^[a-z_]/i) - raise ActiveRecord::Tenanted::BadTenantNameError, "Schema name must start with a letter or underscore: #{schema_name.inspect}" - end - end - - def create_database - # Create schema instead of database - schema = database_path - - with_base_connection do |connection| - quoted_schema = connection.quote_table_name(schema) - - # Create the schema (our patch makes this idempotent with IF NOT EXISTS) - connection.execute("CREATE SCHEMA IF NOT EXISTS #{quoted_schema}") - - # Commit any pending transaction to ensure schema is visible to other connections - # with_temporary_connection may wrap DDL in a transaction - connection.commit_db_transaction if connection.transaction_open? - - # Grant usage permissions (optional but good practice) - # This ensures the schema can be used by the current user - username = db_config.configuration_hash[:username] || "postgres" - connection.execute("GRANT ALL ON SCHEMA #{quoted_schema} TO #{connection.quote_table_name(username)}") - end - end - - def drop_database - # Drop schema instead of database - schema = database_path - - with_base_connection do |connection| - # CASCADE ensures all objects in the schema are dropped - connection.execute("DROP SCHEMA IF EXISTS #{connection.quote_table_name(schema)} CASCADE") - end - end - - def database_exist? - # Check if schema exists - schema = database_path - - with_base_connection do |connection| - result = connection.execute(<<~SQL) - SELECT 1#{' '} - FROM information_schema.schemata#{' '} - WHERE schema_name = '#{connection.quote_string(schema)}' - SQL - result.any? - end - rescue ActiveRecord::NoDatabaseError, PG::Error - false - end - - def database_ready? - database_exist? - end - - def acquire_ready_lock(&block) - # No file-system locking needed for schemas - yield - end - - def ensure_database_directory_exists - # No directory needed for schemas - true - end - - def database_path - # Returns the schema name for this tenant - # For PostgreSQL with schema-based tenancy, we store the schema name separately - # because db_config.database is the base database name - db_config.configuration_hash[:tenant_schema] || db_config.database - end - - def test_workerize(db, test_worker_id) - # For schemas, we append the worker ID to the schema name - test_worker_suffix = "_#{test_worker_id}" - - if db.end_with?(test_worker_suffix) - db - else - db + test_worker_suffix - end - end - - def path_for(schema_name) - # For PostgreSQL schemas, path is just the schema name - schema_name - end - - private - def with_base_connection(&block) - # Connect to the base database (without tenant-specific schema) - # This allows us to create/drop/query schemas - - # Ensure the base database exists first - ensure_base_database_exists - - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(base_db_config, &block) - end - - def ensure_base_database_exists - # Check if base database exists, create if not - base_db_name = extract_base_database_name - - # Connect to postgres maintenance database to check/create base database - maintenance_config = db_config.configuration_hash.dup.merge( - database: "postgres", - database_tasks: false - ) - maintenance_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( - db_config.env_name, - "_maint_#{db_config.name}", - maintenance_config - ) - - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(maintenance_db_config) do |connection| - result = connection.execute("SELECT 1 FROM pg_database WHERE datname = '#{connection.quote_string(base_db_name)}'") - unless result.any? - # Create base database if it doesn't exist - Rails.logger.info "Creating base PostgreSQL database: #{base_db_name}" - create_options = {} - create_options[:encoding] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.key?(:encoding) - create_options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.key?(:collation) - - connection.create_database(base_db_name, create_options) - end - end - rescue PG::Error => e - # Ignore if database already exists (race condition) - raise unless e.message.include?("already exists") - rescue StandardError => e - Rails.logger.error "Failed to ensure base database exists: #{e.class}: #{e.message}" - raise - end - - def base_db_config - # Create a config for the base database - # We extract the base database name from the pattern - base_db_name = extract_base_database_name - - configuration_hash = db_config.configuration_hash.dup.merge( - database: base_db_name, - database_tasks: false, - schema_search_path: "public" # Use public schema for admin operations - ) - - ActiveRecord::DatabaseConfigurations::HashConfig.new( - db_config.env_name, - "_tmp_#{db_config.name}", - configuration_hash - ) - end - - def extract_base_database_name - # Extract base database name from the pattern - # For "test_%{tenant}" (after YAML loading) we need a consistent database name - # We'll use the pattern with "tenanted" as the suffix - # This gives us something like "test_tenanted" - db_config.database.gsub(/%\{tenant\}/, "tenanted") - end - - def schema_name_for(tenant_name) - # Generate schema name from tenant name using the database pattern - # For pattern like "tenant_%%{tenant}", this becomes "tenant_foo" - sprintf(db_config.database, tenant: tenant_name.to_s) - end + # Configure strategy in database.yml: + # postgresql_strategy: schema # or "database" + module PostgreSQL end end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/base.rb b/lib/active_record/tenanted/database_adapters/postgresql/base.rb new file mode 100644 index 00000000..b036da57 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/base.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters + module PostgreSQL + # Base class for PostgreSQL multi-tenancy strategies + # + # PostgreSQL supports two isolation strategies: + # 1. Schema-based: Multiple schemas within a single database (default) + # 2. Database-based: Separate databases per tenant + # + # This base class provides common functionality for both strategies. + class Base + attr_reader :db_config + + def initialize(db_config) + @db_config = db_config + end + + # Abstract methods - must be implemented by subclasses + def tenant_databases + raise NotImplementedError, "#{self.class.name} must implement #tenant_databases" + end + + def create_database + raise NotImplementedError, "#{self.class.name} must implement #create_database" + end + + def drop_database + raise NotImplementedError, "#{self.class.name} must implement #drop_database" + end + + def database_exist? + raise NotImplementedError, "#{self.class.name} must implement #database_exist?" + end + + def database_path + raise NotImplementedError, "#{self.class.name} must implement #database_path" + end + + # Shared validation logic for PostgreSQL identifiers + def validate_tenant_name(tenant_name) + return if tenant_name == "%" || tenant_name == "(.+)" + + identifier = identifier_for(tenant_name) + return if identifier.include?("%{") || identifier.include?("%}") + + # PostgreSQL identifier max length is 63 bytes + if identifier.length > 63 + raise ActiveRecord::Tenanted::BadTenantNameError, + "PostgreSQL identifier too long (max 63 characters): #{identifier.inspect}" + end + + # PostgreSQL identifiers: letters, numbers, underscores, dollar signs, hyphens + if identifier.match?(/[^a-z0-9_$-]/i) + raise ActiveRecord::Tenanted::BadTenantNameError, + "PostgreSQL identifier contains invalid characters " \ + "(only letters, numbers, underscores, $, and hyphens allowed): #{identifier.inspect}" + end + + # Must start with letter or underscore + unless identifier.match?(/^[a-z_]/i) + raise ActiveRecord::Tenanted::BadTenantNameError, + "PostgreSQL identifier must start with a letter or underscore: #{identifier.inspect}" + end + end + + # Returns the identifier (database or schema name) for validation + # Subclasses can override if needed + def identifier_for(tenant_name) + sprintf(db_config.database, tenant: tenant_name.to_s) + end + + def database_ready? + database_exist? + end + + def acquire_ready_lock(&block) + # No file-system locking needed for server-based databases + yield + end + + def ensure_database_directory_exists + # No directory needed for server-based databases + true + end + + def test_workerize(db, test_worker_id) + test_worker_suffix = "_#{test_worker_id}" + + if db.end_with?(test_worker_suffix) + db + else + db + test_worker_suffix + end + end + + def path_for(name) + # For PostgreSQL, path is just the name (database or schema) + name + end + end + end + end + end +end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/database.rb b/lib/active_record/tenanted/database_adapters/postgresql/database.rb new file mode 100644 index 00000000..1133d570 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/database.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters + module PostgreSQL + # PostgreSQL adapter using database-based multi-tenancy + # + # Creates separate PostgreSQL databases for each tenant. + # Similar to how MySQL and SQLite adapters work. + # + # Configuration example: + # adapter: postgresql + # tenanted: true + # postgresql_strategy: database + # database: myapp_%{tenant} + # + # The adapter will: + # - Create separate databases like "myapp_foo", "myapp_bar" + # - Connect to each tenant's database independently + # - Provide stronger isolation than schema-based approach + class Database < Base + def tenant_databases + like_pattern = db_config.database_for("%") + scanner_pattern = db_config.database_for("(.+)") + scanner = Regexp.new("^" + Regexp.escape(scanner_pattern).gsub(Regexp.escape("(.+)"), "(.+)") + "$") + + # Exclude the base database used by schema strategy + # (e.g., "test_tenanted" when pattern is "test_%{tenant}") + base_db_name = db_config.database.gsub(/%\{tenant\}/, "tenanted") + + begin + with_maintenance_connection do |connection| + # Query pg_database for databases matching pattern + result = connection.execute(<<~SQL) + SELECT datname#{' '} + FROM pg_database#{' '} + WHERE datname LIKE '#{connection.quote_string(like_pattern)}' + AND datistemplate = false + ORDER BY datname + SQL + + result.filter_map do |row| + db_name = row["datname"] || row[0] + + # Skip the base database used by schema strategy + next if db_name == base_db_name + + 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, PG::Error => e + Rails.logger.warn "Failed to list tenant databases: #{e.message}" + [] + end + end + + def create_database + with_maintenance_connection do |connection| + create_options = {} + create_options[:encoding] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.key?(:encoding) + create_options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.key?(:collation) + + connection.create_database(database_path, create_options) + end + end + + def drop_database + with_maintenance_connection do |connection| + db_name = connection.quote_table_name(database_path) + + # Terminate all connections to the database before dropping + # PostgreSQL doesn't allow dropping a database with active connections + begin + connection.execute(<<~SQL) + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '#{connection.quote_string(database_path)}' + AND pid <> pg_backend_pid() + SQL + rescue PG::Error => e + # Ignore errors terminating connections (database might not exist) + Rails.logger.debug "Could not terminate connections for #{database_path}: #{e.message}" + end + + connection.execute("DROP DATABASE IF EXISTS #{db_name}") + end + rescue ActiveRecord::NoDatabaseError, PG::Error => e + # Database might not exist or other PostgreSQL error + Rails.logger.debug "Could not drop database #{database_path}: #{e.message}" + end + + def database_exist? + with_maintenance_connection do |connection| + result = connection.execute(<<~SQL) + SELECT 1#{' '} + FROM pg_database#{' '} + WHERE datname = '#{connection.quote_string(database_path)}' + SQL + result.any? + end + rescue ActiveRecord::NoDatabaseError, PG::Error + false + end + + def database_path + db_config.database + end + + def validate_tenant_name(tenant_name) + super + + # Detect configuration errors - schema strategy features with database strategy + if db_config.configuration_hash.key?(:schema_search_path) + raise ActiveRecord::Tenanted::ConfigurationError, + "PostgreSQL database strategy does not use `schema_search_path`. " \ + "Remove this configuration, or use `postgresql_strategy: schema` " \ + "if you want schema-based multi-tenancy." + end + + if db_config.configuration_hash.key?(:tenant_schema) + raise ActiveRecord::Tenanted::ConfigurationError, + "PostgreSQL database strategy does not use `tenant_schema`. " \ + "Remove this configuration, or use `postgresql_strategy: schema` " \ + "if you want schema-based multi-tenancy." + end + end + + private + def with_maintenance_connection(&block) + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(maintenance_config, &block) + end + + def maintenance_config + config_hash = db_config.configuration_hash.dup.merge( + database: "postgres", # Connect to PostgreSQL maintenance database + database_tasks: false + ) + ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "_maint_#{db_config.name}", + config_hash + ) + end + end + end + end + end +end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/factory.rb b/lib/active_record/tenanted/database_adapters/postgresql/factory.rb new file mode 100644 index 00000000..a550aa7e --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/factory.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters + module PostgreSQL + # Factory for creating the appropriate PostgreSQL adapter based on strategy + # + # The strategy is determined by the `postgresql_strategy` configuration option: + # - "schema" (default): Uses schema-based multi-tenancy + # - "database": Uses database-based multi-tenancy + class Factory + VALID_STRATEGIES = %w[schema database].freeze + DEFAULT_STRATEGY = "schema" + + def self.new(db_config) + strategy = db_config.configuration_hash[:postgresql_strategy]&.to_s || DEFAULT_STRATEGY + + # Validate strategy at config load time + unless VALID_STRATEGIES.include?(strategy) + raise ActiveRecord::Tenanted::UnsupportedDatabaseError, + "Invalid PostgreSQL strategy: #{strategy.inspect}. " \ + "Valid options are: #{VALID_STRATEGIES.map(&:inspect).join(', ')}\n\n" \ + "Did you mean #{suggest_strategy(strategy).inspect}?" + end + + case strategy + when "schema" + Schema.new(db_config) + when "database" + Database.new(db_config) + end + end + + def self.suggest_strategy(invalid_strategy) + # Simple typo detection using Levenshtein distance + VALID_STRATEGIES.min_by { |valid| levenshtein_distance(invalid_strategy.to_s, valid) } + end + + def self.levenshtein_distance(s, t) + # Implementation of Levenshtein distance for suggestion + m = s.length + n = t.length + return m if n == 0 + return n if m == 0 + + d = Array.new(m + 1) { Array.new(n + 1) } + + (0..m).each { |i| d[i][0] = i } + (0..n).each { |j| d[0][j] = j } + + (1..n).each do |j| + (1..m).each do |i| + cost = s[i - 1] == t[j - 1] ? 0 : 1 + d[i][j] = [ + d[i - 1][j] + 1, # deletion + d[i][j - 1] + 1, # insertion + d[i - 1][j - 1] + cost, # substitution + ].min + end + end + + d[m][n] + end + end + end + end + end +end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb new file mode 100644 index 00000000..691b15b9 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters + module PostgreSQL + # PostgreSQL adapter using schema-based multi-tenancy + # + # Instead of creating separate databases per tenant, this adapter creates + # separate schemas within a single PostgreSQL database. This is more efficient + # and aligns with PostgreSQL best practices. + # + # Configuration example: + # adapter: postgresql + # tenanted: true + # postgresql_strategy: schema # or omit (this is the default) + # database: tenant_%{tenant} # This becomes the schema name + # + # The adapter will: + # - Connect to a single base database + # - Create/use schemas like "tenant_foo", "tenant_bar", etc. + # - Set schema_search_path to isolate tenants + class Schema < Base + def tenant_databases + # Query for all schemas matching the pattern + schema_pattern = schema_name_for("%") + scanner_pattern = schema_name_for("(.+)") + scanner = Regexp.new("^" + Regexp.escape(scanner_pattern).gsub(Regexp.escape("(.+)"), "(.+)") + "$") + + begin + with_base_connection do |connection| + # PostgreSQL stores schemas in information_schema.schemata + result = connection.execute(<<~SQL) + SELECT schema_name#{' '} + FROM information_schema.schemata#{' '} + WHERE schema_name LIKE '#{connection.quote_string(schema_pattern)}' + AND schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY schema_name + SQL + + result.filter_map do |row| + schema_name = row["schema_name"] || row[0] + match = schema_name.match(scanner) + if match.nil? + Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from schema #{schema_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, PG::Error => e + Rails.logger.warn "Failed to list tenant schemas: #{e.message}" + [] + end + end + + def create_database + # Create schema instead of database + schema = database_path + + with_base_connection do |connection| + quoted_schema = connection.quote_table_name(schema) + + # Create the schema (our patch makes this idempotent with IF NOT EXISTS) + connection.execute("CREATE SCHEMA IF NOT EXISTS #{quoted_schema}") + + # Commit any pending transaction to ensure schema is visible to other connections + # with_temporary_connection may wrap DDL in a transaction + connection.commit_db_transaction if connection.transaction_open? + + # Grant usage permissions (optional but good practice) + # This ensures the schema can be used by the current user + username = db_config.configuration_hash[:username] || "postgres" + connection.execute("GRANT ALL ON SCHEMA #{quoted_schema} TO #{connection.quote_table_name(username)}") + end + end + + def drop_database + # Drop schema instead of database + schema = database_path + + with_base_connection do |connection| + # CASCADE ensures all objects in the schema are dropped + connection.execute("DROP SCHEMA IF EXISTS #{connection.quote_table_name(schema)} CASCADE") + end + end + + def database_exist? + # Check if schema exists + schema = database_path + + with_base_connection do |connection| + result = connection.execute(<<~SQL) + SELECT 1#{' '} + FROM information_schema.schemata#{' '} + WHERE schema_name = '#{connection.quote_string(schema)}' + SQL + result.any? + end + rescue ActiveRecord::NoDatabaseError, PG::Error + false + end + + def database_path + # Returns the schema name for this tenant + # For PostgreSQL with schema-based tenancy, we store the schema name separately + # because db_config.database is the base database name + db_config.configuration_hash[:tenant_schema] || db_config.database + end + + # Prepare tenant config hash with schema-specific settings + def prepare_tenant_config_hash(config_hash, base_config, tenant_name) + schema_name = base_config.database_for(tenant_name) + + config_hash.merge( + schema_search_path: schema_name, + tenant_schema: schema_name, + database: base_config.database.gsub(/%\{tenant\}/, "tenanted") + ) + end + + private + def with_base_connection(&block) + # Connect to the base database (without tenant-specific schema) + # This allows us to create/drop/query schemas + + # Ensure the base database exists first + ensure_base_database_exists + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(base_db_config, &block) + end + + def ensure_base_database_exists + # Check if base database exists, create if not + base_db_name = extract_base_database_name + + # Connect to postgres maintenance database to check/create base database + maintenance_config = db_config.configuration_hash.dup.merge( + database: "postgres", + database_tasks: false + ) + maintenance_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "_maint_#{db_config.name}", + maintenance_config + ) + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(maintenance_db_config) do |connection| + result = connection.execute("SELECT 1 FROM pg_database WHERE datname = '#{connection.quote_string(base_db_name)}'") + unless result.any? + # Create base database if it doesn't exist + Rails.logger.info "Creating base PostgreSQL database: #{base_db_name}" + create_options = {} + create_options[:encoding] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.key?(:encoding) + create_options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.key?(:collation) + + connection.create_database(base_db_name, create_options) + end + end + rescue PG::Error => e + # Ignore if database already exists (race condition) + raise unless e.message.include?("already exists") + rescue StandardError => e + Rails.logger.error "Failed to ensure base database exists: #{e.class}: #{e.message}" + raise + end + + def base_db_config + # Create a config for the base database + # We extract the base database name from the pattern + base_db_name = extract_base_database_name + + configuration_hash = db_config.configuration_hash.dup.merge( + database: base_db_name, + database_tasks: false, + schema_search_path: "public" # Use public schema for admin operations + ) + + ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "_tmp_#{db_config.name}", + configuration_hash + ) + end + + def extract_base_database_name + # Extract base database name from the pattern + # For "test_%{tenant}" (after YAML loading) we need a consistent database name + # We'll use the pattern with "tenanted" as the suffix + # This gives us something like "test_tenanted" + db_config.database.gsub(/%\{tenant\}/, "tenanted") + end + + def schema_name_for(tenant_name) + # Generate schema name from tenant name using the database pattern + # For pattern like "tenant_%{tenant}", this becomes "tenant_foo" + sprintf(db_config.database, tenant: tenant_name.to_s) + end + 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 078c2b18..7d9cdc9b 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -51,19 +51,15 @@ def new_tenant_config(tenant_name) config_hash = configuration_hash.dup.tap do |hash| hash[:tenant] = tenant_name - # For PostgreSQL, use schema-based multi-tenancy - if adapter == "postgresql" - # Keep the same database, but set schema_search_path - # The database pattern like "test_%{tenant}" becomes the schema name - schema_name = database_for(tenant_name) - hash[:schema_search_path] = schema_name - # Store the schema name for the adapter to use - hash[:tenant_schema] = schema_name - # Use a consistent base database name - hash[:database] = database.gsub(/%\{tenant\}/, "tenanted") - else - # For other adapters, use database-per-tenant - hash[:database] = database_for(tenant_name) + # All adapters handle their own database naming + hash[:database] = database_for(tenant_name) + + # Store tenant-specific database/schema name for adapters that need it + hash[:tenant_database] = database_for(tenant_name) + + # Allow adapter to modify config hash (for PostgreSQL schema strategy) + if config_adapter.respond_to?(:prepare_tenant_config_hash) + hash.merge!(config_adapter.prepare_tenant_config_hash(hash, self, tenant_name)) end hash[:tenanted_config_name] = name diff --git a/lib/active_record/tenanted/database_configurations/tenant_config.rb b/lib/active_record/tenanted/database_configurations/tenant_config.rb index c2704f34..27bcef99 100644 --- a/lib/active_record/tenanted/database_configurations/tenant_config.rb +++ b/lib/active_record/tenanted/database_configurations/tenant_config.rb @@ -25,8 +25,8 @@ def new_connection super.tap do |connection| connection.tenant = tenant - # For PostgreSQL with schema-based tenancy, set the schema search path - if adapter == "postgresql" && configuration_hash[:schema_search_path] + # Set schema search path if configured (used by PostgreSQL schema strategy) + if configuration_hash[:schema_search_path] schema = configuration_hash[:schema_search_path] connection.execute("SET search_path TO #{connection.quote_table_name(schema)}") end diff --git a/test/scenarios/postgresql/primary_db/database.yml b/test/scenarios/postgresql/primary_db/database.yml index b2d4b62e..f1234069 100644 --- a/test/scenarios/postgresql/primary_db/database.yml +++ b/test/scenarios/postgresql/primary_db/database.yml @@ -12,6 +12,7 @@ test: primary: <<: *default tenanted: true + postgresql_strategy: schema # Explicit for test clarity (this is the default) database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: diff --git a/test/scenarios/postgresql/primary_db_database_strategy/database.yml b/test/scenarios/postgresql/primary_db_database_strategy/database.yml new file mode 100644 index 00000000..5db57e73 --- /dev/null +++ b/test/scenarios/postgresql/primary_db_database_strategy/database.yml @@ -0,0 +1,22 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: 20 + max_connections: 20 + username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> + +test: + primary: + <<: *default + tenanted: true + postgresql_strategy: database # Use separate databases per tenant + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + shared: + <<: *default + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_shared + migrations_paths: "%{db_path}/shared_migrations" + diff --git a/test/scenarios/postgresql/primary_db_database_strategy/primary_record.rb b/test/scenarios/postgresql/primary_db_database_strategy/primary_record.rb new file mode 100644 index 00000000..f9ad6c58 --- /dev/null +++ b/test/scenarios/postgresql/primary_db_database_strategy/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/postgresql/primary_db_database_strategy/secondary_record.rb b/test/scenarios/postgresql/primary_db_database_strategy/secondary_record.rb new file mode 100644 index 00000000..7fdd930a --- /dev/null +++ b/test/scenarios/postgresql/primary_db_database_strategy/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/postgresql/primary_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/primary_db_database_strategy/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/postgresql/primary_db_database_strategy/subtenant_record.rb b/test/scenarios/postgresql/primary_db_database_strategy/subtenant_record.rb new file mode 100644 index 00000000..5f3a22ba --- /dev/null +++ b/test/scenarios/postgresql/primary_db_database_strategy/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/postgresql/primary_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/primary_db_database_strategy/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/postgresql/primary_named_db/database.yml b/test/scenarios/postgresql/primary_named_db/database.yml index e5bbd2a4..cada7624 100644 --- a/test/scenarios/postgresql/primary_named_db/database.yml +++ b/test/scenarios/postgresql/primary_named_db/database.yml @@ -12,6 +12,7 @@ test: tenanted: <<: *default tenanted: true + postgresql_strategy: schema # Explicit for test clarity (this is the default) database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: diff --git a/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml b/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml new file mode 100644 index 00000000..52c008cc --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml @@ -0,0 +1,23 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: 20 + max_connections: 20 + username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> + +test: + tenanted: + <<: *default + tenanted: true + postgresql_strategy: database # Use separate databases per tenant + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + shared: + <<: *default + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_shared + migrations_paths: "%{db_path}/shared_migrations" + + diff --git a/test/scenarios/postgresql/primary_named_db_database_strategy/primary_record.rb b/test/scenarios/postgresql/primary_named_db_database_strategy/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_database_strategy/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/postgresql/primary_named_db_database_strategy/secondary_record.rb b/test/scenarios/postgresql/primary_named_db_database_strategy/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_database_strategy/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/postgresql/primary_named_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_named_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_database_strategy/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/postgresql/primary_named_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_named_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_database_strategy/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/postgresql/secondary_db/database.yml b/test/scenarios/postgresql/secondary_db/database.yml index b50d0f51..0177b440 100644 --- a/test/scenarios/postgresql/secondary_db/database.yml +++ b/test/scenarios/postgresql/secondary_db/database.yml @@ -16,6 +16,7 @@ test: tenanted: <<: *default tenanted: true + postgresql_strategy: schema # Explicit for test clarity (this is the default) database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" diff --git a/test/scenarios/postgresql/secondary_db_database_strategy/database.yml b/test/scenarios/postgresql/secondary_db_database_strategy/database.yml new file mode 100644 index 00000000..69f5f046 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_database_strategy/database.yml @@ -0,0 +1,23 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: 20 + max_connections: 20 + username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> + +test: + shared: + <<: *default + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_shared + migrations_paths: "%{db_path}/shared_migrations" + tenanted: + <<: *default + tenanted: true + postgresql_strategy: database # Use separate databases per tenant + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} + migrations_paths: "%{db_path}/tenanted_migrations" + + diff --git a/test/scenarios/postgresql/secondary_db_database_strategy/primary_record.rb b/test/scenarios/postgresql/secondary_db_database_strategy/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_database_strategy/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/postgresql/secondary_db_database_strategy/secondary_record.rb b/test/scenarios/postgresql/secondary_db_database_strategy/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_database_strategy/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/postgresql/secondary_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/secondary_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_database_strategy/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/postgresql/secondary_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/secondary_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_database_strategy/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/test_helper.rb b/test/test_helper.rb index 286dc9ff..31cb3921 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -296,14 +296,24 @@ def drop_tentant_databases base_config = all_configs.find { |c| c.configuration_hash[:tenanted] } return unless base_config - tenants = base_config.tenants + begin + tenants = base_config.tenants + rescue => e + # If we can't list tenants (e.g., database doesn't exist), that's fine + Rails.logger.debug "Could not list tenants for cleanup: #{e.message}" + return + end + return if tenants.empty? tenants.each do |tenant_name| - adapter = base_config.new_tenant_config(tenant_name).config_adapter + # Use the tenant config's adapter for proper cleanup + tenant_config = base_config.new_tenant_config(tenant_name) + adapter = tenant_config.config_adapter adapter.drop_database rescue => e - Rails.logger.warn "Failed to cleanup tenant database #{tenant_name}: #{e.message}" + # Log warning but don't fail the teardown + warn "Failed to cleanup tenant database #{tenant_name}: #{e.class}: #{e.message}" end end diff --git a/test/unit/database_adapters_postgresql_base_test.rb b/test/unit/database_adapters_postgresql_base_test.rb new file mode 100644 index 00000000..6574bad8 --- /dev/null +++ b/test/unit/database_adapters_postgresql_base_test.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Base do + let(:db_config) do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}" } + ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + end + let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Base.new(db_config) } + + describe "abstract methods" do + test "tenant_databases raises NotImplementedError" do + error = assert_raises(NotImplementedError) do + adapter.tenant_databases + end + assert_match(/must implement #tenant_databases/, error.message) + end + + test "create_database raises NotImplementedError" do + error = assert_raises(NotImplementedError) do + adapter.create_database + end + assert_match(/must implement #create_database/, error.message) + end + + test "drop_database raises NotImplementedError" do + error = assert_raises(NotImplementedError) do + adapter.drop_database + end + assert_match(/must implement #drop_database/, error.message) + end + + test "database_exist? raises NotImplementedError" do + error = assert_raises(NotImplementedError) do + adapter.database_exist? + end + assert_match(/must implement #database_exist\?/, error.message) + end + + test "database_path raises NotImplementedError" do + error = assert_raises(NotImplementedError) do + adapter.database_path + end + assert_match(/must implement #database_path/, error.message) + end + end + + describe "path_for" do + test "returns name as-is" do + database = "myapp_production" + expected = "myapp_production" + assert_equal(expected, adapter.path_for(database)) + end + end + + describe "test_workerize" do + test "appends test worker id to name" do + db = "myapp_test" + test_worker_id = 1 + expected = "myapp_test_1" + assert_equal(expected, adapter.test_workerize(db, test_worker_id)) + end + + test "does not double-suffix if already present" do + db = "myapp_test_1" + test_worker_id = 1 + expected = "myapp_test_1" + assert_equal(expected, adapter.test_workerize(db, test_worker_id)) + end + end + + describe "validate_tenant_name" do + test "allows valid tenant names" do + assert_nothing_raised do + adapter.validate_tenant_name("tenant1") + adapter.validate_tenant_name("tenant_123") + adapter.validate_tenant_name("tenant$foo") + adapter.validate_tenant_name("tenant-name") # hyphens are allowed + end + end + + test "raises error for identifiers that are too long" do + # Max is 63 characters, so with "myapp_" prefix (6 chars), tenant name can be max 57 chars + # Testing with 58 chars should fail (6 + 58 = 64 > 63) + long_name = "a" * 58 + error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do + adapter.validate_tenant_name(long_name) + end + assert_match(/too long/, error.message) + end + + test "allows identifiers at exactly 63 characters" do + # With "myapp_" prefix (6 chars), tenant name of 57 chars = exactly 63 total + max_length_name = "a" * 57 + assert_nothing_raised do + adapter.validate_tenant_name(max_length_name) + end + end + + test "raises error for identifiers with invalid characters" do + error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do + adapter.validate_tenant_name("tenant.name") # dots are not allowed + end + assert_match(/invalid characters/, error.message) + end + + test "raises error for identifiers starting with a number" do + # Create a config where the pattern would result in an identifier starting with a number + config_hash = { adapter: "postgresql", database: "%{tenant}_schema" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Base.new(db_config) + + error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do + adapter.validate_tenant_name("1tenant") + end + assert_match(/must start with a letter or underscore/, error.message) + end + + test "allows special validation patterns" do + assert_nothing_raised do + adapter.validate_tenant_name("%") + adapter.validate_tenant_name("(.+)") + end + end + end + + describe "database_ready?" do + test "delegates to database_exist?" do + # Since database_exist? is abstract, we can't test this fully + # but we can verify it's defined + assert_respond_to adapter, :database_ready? + end + end + + describe "acquire_ready_lock" do + test "yields to block" do + block_called = false + adapter.acquire_ready_lock do + block_called = true + end + assert block_called + end + end + + describe "ensure_database_directory_exists" do + test "returns true" do + assert_equal true, adapter.ensure_database_directory_exists + end + end +end diff --git a/test/unit/database_adapters_postgresql_database_test.rb b/test/unit/database_adapters_postgresql_database_test.rb new file mode 100644 index 00000000..bca938fb --- /dev/null +++ b/test/unit/database_adapters_postgresql_database_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database do + let(:db_config) do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "database" } + ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + end + let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database.new(db_config) } + + describe "database_path" do + test "returns database from config" do + assert_equal "myapp_%{tenant}", adapter.database_path + end + end + + describe "validate_tenant_name" do + test "raises error if schema_search_path is configured" do + config_hash = { + adapter: "postgresql", + database: "myapp_%{tenant}", + postgresql_strategy: "database", + schema_search_path: "myapp_foo", + } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database.new(db_config) + + error = assert_raises(ActiveRecord::Tenanted::ConfigurationError) do + adapter.validate_tenant_name("foo") + end + + assert_match(/does not use `schema_search_path`/, error.message) + assert_match(/postgresql_strategy: schema/, error.message) + end + + test "raises error if tenant_schema is configured" do + config_hash = { + adapter: "postgresql", + database: "myapp_%{tenant}", + postgresql_strategy: "database", + tenant_schema: "myapp_foo", + } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database.new(db_config) + + error = assert_raises(ActiveRecord::Tenanted::ConfigurationError) do + adapter.validate_tenant_name("foo") + end + + assert_match(/does not use `tenant_schema`/, error.message) + assert_match(/postgresql_strategy: schema/, error.message) + end + + test "allows valid configuration without schema-specific settings" do + assert_nothing_raised do + adapter.validate_tenant_name("foo") + adapter.validate_tenant_name("bar") + end + end + end + + describe "identifier_for" do + test "returns database name for tenant" do + result = adapter.identifier_for("foo") + assert_equal "myapp_foo", result + end + end +end diff --git a/test/unit/database_adapters_postgresql_factory_test.rb b/test/unit/database_adapters_postgresql_factory_test.rb new file mode 100644 index 00000000..4cf6acef --- /dev/null +++ b/test/unit/database_adapters_postgresql_factory_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory do + describe "strategy selection" do + test "returns Schema adapter when strategy is 'schema'" do + config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "schema" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) + + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter + end + + test "returns Database adapter when strategy is 'database'" do + config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "database" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) + + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter + end + + test "returns Schema adapter when strategy is not specified (default)" do + config_hash = { adapter: "postgresql", database: "test_%{tenant}" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) + + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter + end + + test "raises error for invalid strategy" do + config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "invalid" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + + error = assert_raises(ActiveRecord::Tenanted::UnsupportedDatabaseError) do + ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) + end + + assert_match(/Invalid PostgreSQL strategy/, error.message) + assert_match(/"invalid"/, error.message) + assert_match(/Valid options are/, error.message) + end + + test "suggests correct strategy for typos" do + config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "schemas" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + + error = assert_raises(ActiveRecord::Tenanted::UnsupportedDatabaseError) do + ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) + end + + assert_match(/Did you mean "schema"\?/, error.message) + end + + test "handles strategy as symbol" do + config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: :database } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) + + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter + end + end + + describe "suggest_strategy" do + test "suggests 'schema' for 'schemas'" do + suggestion = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.suggest_strategy("schemas") + assert_equal "schema", suggestion + end + + test "suggests 'database' for 'databases'" do + suggestion = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.suggest_strategy("databases") + assert_equal "database", suggestion + end + + test "suggests closest match for completely wrong input" do + suggestion = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.suggest_strategy("xyz") + assert_includes [ "schema", "database" ], suggestion + end + end +end diff --git a/test/unit/database_adapters_postgresql_schema_test.rb b/test/unit/database_adapters_postgresql_schema_test.rb new file mode 100644 index 00000000..732846ec --- /dev/null +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema do + let(:db_config) do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "schema" } + ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + end + let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) } + + describe "database_path" do + test "returns tenant_schema from config if present" do + db_config_with_schema = Object.new + def db_config_with_schema.database; "myapp_%{tenant}"; end + def db_config_with_schema.configuration_hash + { tenant_schema: "myapp_foo" } + end + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_schema) + assert_equal "myapp_foo", adapter.database_path + end + + test "returns database pattern if tenant_schema not present" do + assert_equal "myapp_%{tenant}", adapter.database_path + end + end + + describe "prepare_tenant_config_hash" do + test "adds schema-specific configuration" do + base_config = Object.new + def base_config.database; "myapp_%{tenant}"; end + def base_config.database_for(tenant_name) + "myapp_#{tenant_name}" + end + + config_hash = { tenant: "foo", database: "myapp_foo" } + result = adapter.prepare_tenant_config_hash(config_hash, base_config, "foo") + + assert_equal "myapp_foo", result[:schema_search_path] + assert_equal "myapp_foo", result[:tenant_schema] + assert_equal "myapp_tenanted", result[:database] + end + + test "uses consistent base database name" do + base_config = Object.new + def base_config.database; "test_%{tenant}"; end + def base_config.database_for(tenant_name) + "test_#{tenant_name}" + end + + config_hash = { tenant: "bar" } + result = adapter.prepare_tenant_config_hash(config_hash, base_config, "bar") + + # Base database should replace %{tenant} with "tenanted" + assert_equal "test_tenanted", result[:database] + end + end + + describe "identifier_for" do + test "returns schema name for tenant" do + result = adapter.identifier_for("foo") + assert_equal "myapp_foo", result + end + end +end diff --git a/test/unit/database_adapters_postgresql_test.rb b/test/unit/database_adapters_postgresql_test.rb index d217f670..a6191b98 100644 --- a/test/unit/database_adapters_postgresql_test.rb +++ b/test/unit/database_adapters_postgresql_test.rb @@ -2,91 +2,40 @@ require "test_helper" -describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL do - let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL.new(Object.new) } - - describe "path_for" do - test "returns database name as-is" do - database = "myapp_production" - expected = "myapp_production" - assert_equal(expected, adapter.path_for(database)) - end - end - - describe "test_workerize" do - test "appends test worker id to database name" do - db = "myapp_test" - test_worker_id = 1 - expected = "myapp_test_1" - assert_equal(expected, adapter.test_workerize(db, test_worker_id)) - end - - test "does not double-suffix if already present" do - db = "myapp_test_1" - test_worker_id = 1 - expected = "myapp_test_1" - assert_equal(expected, adapter.test_workerize(db, test_worker_id)) - end - end - - describe "validate_tenant_name" do - let(:db_config) do +# This file tests the PostgreSQL adapter factory and default behavior +# For detailed tests of each strategy, see: +# - database_adapters_postgresql_schema_test.rb +# - database_adapters_postgresql_database_test.rb +# - database_adapters_postgresql_base_test.rb +# - database_adapters_postgresql_factory_test.rb + +describe "ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL" do + describe "default adapter" do + test "creates Schema adapter by default" do config_hash = { adapter: "postgresql", database: "myapp_%{tenant}" } - ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - end - let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL.new(db_config) } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - test "allows valid tenant names" do - assert_nothing_raised do - adapter.validate_tenant_name("tenant1") - adapter.validate_tenant_name("tenant_123") - adapter.validate_tenant_name("tenant$foo") - adapter.validate_tenant_name("tenant-name") # hyphens are allowed - end - end + adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) - test "raises error for database names that are too long" do - # Max is 63 characters, so with "myapp_" prefix (6 chars), tenant name can be max 57 chars - # Testing with 58 chars should fail (6 + 58 = 64 > 63) - long_name = "a" * 58 - error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do - adapter.validate_tenant_name(long_name) - end - assert_match(/too long/, error.message) + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter end - test "allows database names at exactly 63 characters" do - # With "myapp_" prefix (6 chars), tenant name of 57 chars = exactly 63 total - max_length_name = "a" * 57 - assert_nothing_raised do - adapter.validate_tenant_name(max_length_name) - end - end + test "creates Schema adapter when strategy is explicitly 'schema'" do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "schema" } + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - test "raises error for database names with invalid characters" do - error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do - adapter.validate_tenant_name("tenant.name") # dots are not allowed - end - assert_match(/invalid characters/, error.message) + adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) + + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter end - test "raises error for schema names starting with a number" do - # Create a config where the pattern would result in a schema starting with a number - config_hash = { adapter: "postgresql", database: "%{tenant}_schema" } + test "creates Database adapter when strategy is 'database'" do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "database" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL.new(db_config) - error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do - adapter.validate_tenant_name("1tenant") - end - assert_match(/must start with a letter or underscore/, error.message) - end + adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) - test "allows special validation patterns" do - assert_nothing_raised do - adapter.validate_tenant_name("%") - adapter.validate_tenant_name("(.+)") - end + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter end end end diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 07cff6ef..4252250e 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -161,7 +161,7 @@ end end - for_each_scenario(except: { primary_db: [ :subtenant_record ] }) do + for_each_scenario(except: { primary_db: [ :subtenant_record ], primary_db_database_strategy: [ :subtenant_record ] }) do describe "concrete classes" do test "concrete classes can call current_tenant=" do TenantedApplicationRecord.current_tenant = "foo" @@ -333,7 +333,7 @@ end end - for_each_scenario(except: { primary_db: [ :subtenant_record ] }) do + for_each_scenario(except: { primary_db: [ :subtenant_record ], primary_db_database_strategy: [ :subtenant_record ] }) do setup do TenantedApplicationRecord.create_tenant("foo") TenantedApplicationRecord.create_tenant("bar") From f971ea584bd5b287a8c282f36ad99311952310ac Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Fri, 19 Dec 2025 12:56:46 -0600 Subject: [PATCH 54/64] Add documentation for PostgreSQL tenant name constraints --- GUIDE.md | 32 +++++++++++++++++++ .../database_adapters_postgresql_base_test.rb | 7 ++++ 2 files changed, 39 insertions(+) diff --git a/GUIDE.md b/GUIDE.md index 9bd9a60f..8af5ba92 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -384,6 +384,38 @@ To change strategies, you'll need to: **Note:** There is no automated migration tool. Plan strategy choice carefully before production deployment. +##### PostgreSQL Tenant Name Constraints + +PostgreSQL has strict naming conventions for identifiers (database names and schema names). When using PostgreSQL with this gem, tenant names are subject to the following constraints: + +**Allowed Characters:** +- Letters (a-z, A-Z) +- Numbers (0-9) +- Underscores (`_`) +- Dollar signs (`$`) +- Hyphens (`-`) + +**Additional Constraints:** +- **Maximum Length:** 63 characters total (including any database prefix) +- **First Character:** Must be a letter or underscore (cannot start with a number or special character) +- **Forward Slashes:** Not allowed in PostgreSQL identifiers + +**Examples:** + +✅ **Valid tenant names:** +- `"tenant-one"` +- `"tenant_one"` +- `"tenant123"` +- `"$tenant"` +- `"_tenant"` + +❌ **Invalid tenant names:** +- `"tenant.name"` (contains dot) +- `"tenant name"` (contains space) +- `"123tenant"` (starts with number) +- `"tenant@domain"` (contains @ symbol) +- `"my-org/tenant"` (contains forward slash) + ### 2.3 Configuring `max_connection_pools` By default, Active Record Tenanted will cap the number of tenanted connection pools to 50. Setting a limit on the number of "live" connection pools at any one time provides control over the number of file descriptors used for database connections. For SQLite databases, it's also an important control on the amount of memory used. diff --git a/test/unit/database_adapters_postgresql_base_test.rb b/test/unit/database_adapters_postgresql_base_test.rb index 6574bad8..03118492 100644 --- a/test/unit/database_adapters_postgresql_base_test.rb +++ b/test/unit/database_adapters_postgresql_base_test.rb @@ -123,6 +123,13 @@ adapter.validate_tenant_name("(.+)") end end + + test "raises error for tenant names with forward slashes" do + error = assert_raises(ActiveRecord::Tenanted::BadTenantNameError) do + adapter.validate_tenant_name("myapp/tenant") + end + assert_match(/invalid characters/, error.message) + end end describe "database_ready?" do From 1d42719f5c99501c92c3dc9ab04e58f58d85d4c8 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Fri, 19 Dec 2025 13:43:42 -0600 Subject: [PATCH 55/64] Allow PosgreSQL schema to drop entire colacated database --- .../database_adapters/postgresql/schema.rb | 21 ++++++++++ lib/active_record/tenanted/database_tasks.rb | 10 ++++- ...atabase_adapters_postgresql_schema_test.rb | 17 ++++++++ test/unit/database_tasks_test.rb | 39 +++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index 691b15b9..be69ccfe 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -94,6 +94,27 @@ def drop_database end end + def drop_colocated_database + # For schema-based strategies we want to, drop the entire colocated database + # using Rails' DatabaseTasks.drop to fully integrate with Rails + base_db_name = extract_base_database_name + + # Create a non-tenanted database config for the base database + # We strip out tenanted-specific keys to create a regular Rails config + # Rails' drop method will handle connection, termination, logging, etc. + base_config_hash = db_config.configuration_hash + .except(:tenanted, :tenant_schema, :schema_search_path) + .merge(database: base_db_name) + + base_drop_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + db_config.name, + base_config_hash + ) + + ActiveRecord::Tasks::DatabaseTasks.drop(base_drop_config) + end + def database_exist? # Check if schema exists schema = database_path diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index 880c34ac..f8b6833d 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -34,8 +34,14 @@ def migrate_tenant(tenant = set_current_tenant) end def drop_all - tenants.each do |tenant| - drop_tenant(tenant) + # Check if the adapter is colocated and we can drop the entire database + # (e.g., PostgreSQL schema strategy) + if config.config_adapter.respond_to?(:drop_colocated_database) + config.config_adapter.drop_colocated_database + else + tenants.each do |tenant| + drop_tenant(tenant) + end end end diff --git a/test/unit/database_adapters_postgresql_schema_test.rb b/test/unit/database_adapters_postgresql_schema_test.rb index 732846ec..a1fc4479 100644 --- a/test/unit/database_adapters_postgresql_schema_test.rb +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -63,4 +63,21 @@ def base_config.database_for(tenant_name) assert_equal "myapp_foo", result end end + + describe "drop_colocated_database" do + test "delegates to Rails DatabaseTasks.drop with base database config" do + # This test verifies that drop_colocated_database fully integrates with Rails + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) + base_db_name = "myapp_tenanted" + + # Verify DatabaseTasks.drop is called with the correct config + ActiveRecord::Tasks::DatabaseTasks.stub :drop, ->(config) do + assert_equal base_db_name, config.database + assert_equal "test", config.env_name + assert_equal "postgresql", config.configuration_hash[:adapter] + end do + adapter.drop_colocated_database + end + end + end end diff --git a/test/unit/database_tasks_test.rb b/test/unit/database_tasks_test.rb index b638d25a..dffd4069 100644 --- a/test/unit/database_tasks_test.rb +++ b/test/unit/database_tasks_test.rb @@ -39,6 +39,45 @@ end end end + + for_each_scenario only: { adapter: :postgresql } do + let(:tenants) { %w[foo bar] } + + setup do + tenants.each do |tenant| + TenantedApplicationRecord.create_tenant(tenant) + end + end + + test "drops colocated base database when using schema strategy" do + skip unless base_config.config_adapter.respond_to?(:drop_colocated_database) + + # Get the base database name + base_db_name = base_config.config_adapter.send(:extract_base_database_name) + + # Verify base database exists + maintenance_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + base_config.env_name, + "_test_maint", + base_config.configuration_hash.dup.merge(database: "postgres", database_tasks: false) + ) + + base_db_exists = -> do + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(maintenance_config) do |conn| + result = conn.execute("SELECT 1 FROM pg_database WHERE datname = '#{conn.quote_string(base_db_name)}'") + result.any? + end + end + + assert base_db_exists.call, "Base database #{base_db_name} should exist before drop_all" + + # Drop all databases + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).drop_all + + # Verify base database was dropped + assert_not base_db_exists.call, "Base database #{base_db_name} should be dropped after drop_all" + end + end end describe ".migrate_tenant" do From 3aaa5c36a551ed432c58d148e8984651089b2bb1 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Fri, 19 Dec 2025 15:24:49 -0600 Subject: [PATCH 56/64] Create colocated adapter and added rake db:create functionality --- .../tenanted/database_adapters/colocated.rb | 32 +++++++ .../database_adapters/postgresql/schema.rb | 27 +++++- lib/active_record/tenanted/database_tasks.rb | 24 ++++- test/unit/database_adapters_colocated_test.rb | 89 +++++++++++++++++++ ...atabase_adapters_postgresql_schema_test.rb | 25 +++++- test/unit/database_tasks_test.rb | 2 +- 6 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 lib/active_record/tenanted/database_adapters/colocated.rb create mode 100644 test/unit/database_adapters_colocated_test.rb diff --git a/lib/active_record/tenanted/database_adapters/colocated.rb b/lib/active_record/tenanted/database_adapters/colocated.rb new file mode 100644 index 00000000..5e41ea85 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/colocated.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters + # Module for database adapters that use a colocated multi-tenancy strategy. + # + # A "colocated" strategy means all tenants share a single database server resource, + # but are isolated using database-level constructs: + # - PostgreSQL schema strategy: All tenant schemas in one database + # - Future: Other colocated strategies (e.g., row-level with tenant_id) + module Colocated + # Returns true to indicate this adapter uses a colocated strategy + def colocated? + true + end + + # Create the colocated database that will contain all tenant data. + # Must be implemented by the including adapter. + def create_colocated_database + raise NotImplementedError, "#{self.class.name} must implement #create_colocated_database" + end + + # Drop the colocated database and all tenant data within it. + # Must be implemented by the including adapter. + def drop_colocated_database + raise NotImplementedError, "#{self.class.name} must implement #drop_colocated_database" + end + end + end + end +end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index be69ccfe..dbff2283 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -21,6 +21,8 @@ module PostgreSQL # - Create/use schemas like "tenant_foo", "tenant_bar", etc. # - Set schema_search_path to isolate tenants class Schema < Base + include Colocated + def tenant_databases # Query for all schemas matching the pattern schema_pattern = schema_name_for("%") @@ -94,9 +96,30 @@ def drop_database end end + def create_colocated_database + # Create the colocated database that will contain all tenant schemas. + # Use Rails' DatabaseTasks.create to fully integrate with Rails + base_db_name = extract_base_database_name + + # Create a non-tenanted database config for the base database + # We strip out tenanted-specific keys to create a regular Rails + # config Rails' create method will handle connection, logging, etc. + base_config_hash = db_config.configuration_hash + .except(:tenanted, :tenant_schema, :schema_search_path) + .merge(database: base_db_name) + + base_create_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + db_config.name, + base_config_hash + ) + + ActiveRecord::Tasks::DatabaseTasks.create(base_create_config) + end + def drop_colocated_database - # For schema-based strategies we want to, drop the entire colocated database - # using Rails' DatabaseTasks.drop to fully integrate with Rails + # Drop the entire colocated database using Rails' DatabaseTasks.drop + # to fully integrate with Rails base_db_name = extract_base_database_name # Create a non-tenanted database config for the base database diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index f8b6833d..fce50823 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -33,10 +33,17 @@ def migrate_tenant(tenant = set_current_tenant) migrate(db_config) end + def create_all + # For colocated strategies, create the shared database that will contain all tenants + # (e.g., PostgreSQL schema strategy creates a single base database for all schemas) + # For isolated strategies, individual databases are created on-demand via migrate_tenant + config.config_adapter.create_colocated_database if adapter_colocated? + end + def drop_all - # Check if the adapter is colocated and we can drop the entire database - # (e.g., PostgreSQL schema strategy) - if config.config_adapter.respond_to?(:drop_colocated_database) + # For colocated strategies, drop the shared database containing all tenants + # For isolated strategies, drop each tenant database individually + if adapter_colocated? config.config_adapter.drop_colocated_database else tenants.each do |tenant| @@ -138,6 +145,12 @@ def verbose? def register_rake_tasks name = config.name + desc "Create tenanted #{name} databases for current environment" + task "db:create:#{name}" => "load_config" do + create_all + end + task "db:create" => "db:create:#{name}" + desc "Migrate tenanted #{name} databases for current environment" task "db:migrate:#{name}" => "load_config" do verbose_was = ActiveRecord::Migration.verbose @@ -176,6 +189,11 @@ def register_rake_tasks task "db:reset:#{name}" => [ "db:drop:#{name}", "db:migrate:#{name}" ] task "db:reset" => "db:reset:#{name}" end + + private + def adapter_colocated? + config.config_adapter.respond_to?(:colocated?) && config.config_adapter.colocated? + end end end end diff --git a/test/unit/database_adapters_colocated_test.rb b/test/unit/database_adapters_colocated_test.rb new file mode 100644 index 00000000..0f81c439 --- /dev/null +++ b/test/unit/database_adapters_colocated_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::Colocated do + # Create a minimal class that includes the Colocated module + let(:minimal_class) do + Class.new do + include ActiveRecord::Tenanted::DatabaseAdapters::Colocated + + attr_reader :db_config + + def initialize(db_config) + @db_config = db_config + end + end + end + + let(:db_config) do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}" } + ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) + end + + let(:adapter) { minimal_class.new(db_config) } + + describe "#colocated?" do + test "returns true when module is included" do + assert_equal true, adapter.colocated? + end + end + + describe "#create_colocated_database" do + test "raises NotImplementedError if not implemented" do + error = assert_raises(NotImplementedError) do + adapter.create_colocated_database + end + + assert_match(/must implement #create_colocated_database/, error.message) + end + end + + describe "#drop_colocated_database" do + test "raises NotImplementedError if not implemented" do + error = assert_raises(NotImplementedError) do + adapter.drop_colocated_database + end + + assert_match(/must implement #drop_colocated_database/, error.message) + end + end + + describe "when methods are implemented" do + let(:implemented_class) do + Class.new do + include ActiveRecord::Tenanted::DatabaseAdapters::Colocated + + attr_reader :db_config, :created, :dropped + + def initialize(db_config) + @db_config = db_config + @created = false + @dropped = false + end + + def create_colocated_database + @created = true + end + + def drop_colocated_database + @dropped = true + end + end + end + + let(:implemented_adapter) { implemented_class.new(db_config) } + + test "create_colocated_database works when implemented" do + assert_equal false, implemented_adapter.created + implemented_adapter.create_colocated_database + assert_equal true, implemented_adapter.created + end + + test "drop_colocated_database works when implemented" do + assert_equal false, implemented_adapter.dropped + implemented_adapter.drop_colocated_database + assert_equal true, implemented_adapter.dropped + end + end +end diff --git a/test/unit/database_adapters_postgresql_schema_test.rb b/test/unit/database_adapters_postgresql_schema_test.rb index a1fc4479..99fc0913 100644 --- a/test/unit/database_adapters_postgresql_schema_test.rb +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -64,11 +64,34 @@ def base_config.database_for(tenant_name) end end + describe "colocated?" do + test "returns true for schema-based strategy" do + assert_equal true, adapter.colocated? + end + end + + describe "create_colocated_database" do + test "delegates to Rails DatabaseTasks.create with base database config" do + # This test verifies that create_colocated_database fully integrates with Rails + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) + base_db_name = "myapp" + + # Verify DatabaseTasks.create is called with the correct config + ActiveRecord::Tasks::DatabaseTasks.stub :create, ->(config) do + assert_equal base_db_name, config.database + assert_equal "test", config.env_name + assert_equal "postgresql", config.configuration_hash[:adapter] + end do + adapter.create_colocated_database + end + end + end + describe "drop_colocated_database" do test "delegates to Rails DatabaseTasks.drop with base database config" do # This test verifies that drop_colocated_database fully integrates with Rails adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) - base_db_name = "myapp_tenanted" + base_db_name = "myapp" # Verify DatabaseTasks.drop is called with the correct config ActiveRecord::Tasks::DatabaseTasks.stub :drop, ->(config) do diff --git a/test/unit/database_tasks_test.rb b/test/unit/database_tasks_test.rb index dffd4069..d51e0195 100644 --- a/test/unit/database_tasks_test.rb +++ b/test/unit/database_tasks_test.rb @@ -50,7 +50,7 @@ end test "drops colocated base database when using schema strategy" do - skip unless base_config.config_adapter.respond_to?(:drop_colocated_database) + skip unless base_config.config_adapter.respond_to?(:colocated?) # Get the base database name base_db_name = base_config.config_adapter.send(:extract_base_database_name) From 95745cea0e0167dff43f76af334e97f7f9167368 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Sat, 20 Dec 2025 15:39:17 -0600 Subject: [PATCH 57/64] Fix PendingMigrationError --- lib/active_record/tenanted/tenant.rb | 20 +++++++++--- test/unit/tenant_test.rb | 46 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dc91b979..37b28451 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -138,9 +138,16 @@ def create_tenant(tenant_name, if_not_exists: false, &block) unless adapter.database_exist? adapter.create_database - with_tenant(tenant_name) do - connection_pool(schema_version_check: false) - ActiveRecord::Tenanted::DatabaseTasks.new(base_config).migrate_tenant(tenant_name) + # Disable schema version checks during tenant creation and migration + # This prevents PendingMigrationError when the tenant schema is first being set up + Thread.current[:ar_tenanted_skip_schema_check] = true + begin + with_tenant(tenant_name) do + connection_pool(schema_version_check: false) + ActiveRecord::Tenanted::DatabaseTasks.new(base_config).migrate_tenant(tenant_name) + end + ensure + Thread.current[:ar_tenanted_skip_schema_check] = false end created_db = true @@ -195,7 +202,10 @@ def connection_pool(schema_version_check: true) # :nodoc: pool = retrieve_connection_pool(strict: false) if pool.nil? - _create_tenanted_pool(schema_version_check: schema_version_check) + # Check thread-local override for schema version check during tenant creation + skip_check = Thread.current[:ar_tenanted_skip_schema_check] + effective_schema_check = skip_check ? false : schema_version_check + _create_tenanted_pool(schema_version_check: effective_schema_check) pool = retrieve_connection_pool(strict: true) end end @@ -213,7 +223,7 @@ def tenanted_root_config # :nodoc: def _create_tenanted_pool(schema_version_check: true) # :nodoc: # ensure all classes use the same connection pool - return superclass._create_tenanted_pool unless connection_class? + return superclass._create_tenanted_pool(schema_version_check: schema_version_check) unless connection_class? tenant = current_tenant db_config = tenanted_root_config.new_tenant_config(tenant) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 4252250e..ac79ecab 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -828,6 +828,52 @@ end end end + + test "does not raise PendingMigrationError during tenant creation" do + # This test ensures that the schema version check is properly disabled + # during tenant creation, preventing PendingMigrationError from being + # raised when the tenant schema is first being set up + assert_nothing_raised do + TenantedApplicationRecord.create_tenant("foo") + end + + # Verify the tenant was created and migrations were run + assert(TenantedApplicationRecord.tenant_exist?("foo")) + + version = TenantedApplicationRecord.with_tenant("foo") do + User.connection_pool.migration_context.current_version + end + + # Verify at least one migration was run + assert_operator(version, :>, 0) + end + + test "thread-local schema version check flag is properly cleaned up" do + # Ensure the thread-local flag is not set before tenant creation + assert_nil(Thread.current[:ar_tenanted_skip_schema_check]) + + TenantedApplicationRecord.create_tenant("foo") + + # Ensure the thread-local flag is cleaned up after tenant creation + assert_equal(false, Thread.current[:ar_tenanted_skip_schema_check]) + end + + test "schema version check is enforced after tenant creation" do + # Create a tenant + TenantedApplicationRecord.create_tenant("foo") do + # Force removal of connection pool to trigger recreation + TenantedApplicationRecord.remove_connection + end + + # Add a new migration file to simulate pending migrations + with_new_migration_file + + # Accessing the tenant should now raise PendingMigrationError + # because schema version check is re-enabled + assert_raises(ActiveRecord::PendingMigrationError) do + TenantedApplicationRecord.with_tenant("foo") { User.count } + end + end end end From db0198106d1eaf76973fd34af6133a14ad29f5ba Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Sun, 21 Dec 2025 10:46:53 -0600 Subject: [PATCH 58/64] Change configuration to use schema_name_pattern instead of postgresql_strategy --- GUIDE.md | 22 +-- .../tenanted/database_adapters/postgresql.rb | 7 +- .../database_adapters/postgresql/database.rb | 5 +- .../database_adapters/postgresql/factory.rb | 55 +------ .../database_adapters/postgresql/schema.rb | 51 +++++-- .../postgresql/colocated_schema/database.yml | 21 +++ .../colocated_schema/primary_record.rb | 9 ++ .../20250203191115_create_users.rb | 11 ++ .../postgresql/primary_db/database.yml | 2 +- .../primary_db_database_strategy/database.yml | 1 - .../postgresql/primary_named_db/database.yml | 1 - .../database.yml | 1 - .../postgresql/secondary_db/database.yml | 1 - .../database.yml | 1 - ...abase_adapters_postgresql_database_test.rb | 22 +-- ...tabase_adapters_postgresql_factory_test.rb | 68 ++------- ...atabase_adapters_postgresql_schema_test.rb | 125 +++++++++++++++- .../unit/database_adapters_postgresql_test.rb | 19 +-- test/unit/postgresql_colocated_schema_test.rb | 141 ++++++++++++++++++ 19 files changed, 381 insertions(+), 182 deletions(-) create mode 100644 test/scenarios/postgresql/colocated_schema/database.yml create mode 100644 test/scenarios/postgresql/colocated_schema/primary_record.rb create mode 100644 test/scenarios/postgresql/colocated_schema/tenanted_migrations/20250203191115_create_users.rb create mode 100644 test/unit/postgresql_colocated_schema_test.rb diff --git a/GUIDE.md b/GUIDE.md index 8af5ba92..ad78ef2e 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -274,9 +274,9 @@ end #### 2.2.1 PostgreSQL Multi-Tenancy Strategies -PostgreSQL supports two isolation strategies, configurable via the `postgresql_strategy` option in `database.yml`. +PostgreSQL supports two isolation strategies, configurable via the `schema_name_pattern` option in `database.yml`. -##### Schema-Based Multi-Tenancy (Default) +##### Schema-Based Multi-Tenancy Uses PostgreSQL schemas within a single database. This is the default strategy and is recommended for most use cases. @@ -287,16 +287,17 @@ production: primary: adapter: postgresql tenanted: true - # postgresql_strategy: schema # Optional - this is the default - database: myapp_%{tenant} + database: myapp_production # Static database name + schema_name_pattern: "%{tenant}" # Dynamic schema names host: localhost ``` In this configuration: -- A single PostgreSQL database named `myapp_tenanted` is created -- Each tenant gets its own schema: `myapp_foo`, `myapp_bar`, etc. +- A single PostgreSQL database named `myapp_production` is created (static name) +- Each tenant gets its own schema using the pattern: tenant IDs directly (e.g., `account-123`, `account-456`) - The `schema_search_path` is set automatically to isolate tenants - All tables and data are stored within the tenant-specific schema +- **Auto-detection:** When `schema_name_pattern` is present **Advantages:** - **Resource Efficient**: Single database process serves all tenants @@ -320,9 +321,8 @@ Creates separate PostgreSQL databases for each tenant. Similar to how MySQL and production: primary: adapter: postgresql - tenanted: true - postgresql_strategy: database # Explicitly use database-per-tenant database: myapp_%{tenant} + tenanted: true host: localhost ``` @@ -359,7 +359,7 @@ In this configuration: ##### Choosing a Strategy -**Use Schema Strategy (default) when:** +**Use Schema Strategy with `schema_name_pattern` (recommended) when:** - You have many tenants (dozens to thousands) - Resource efficiency is important - You're following PostgreSQL best practices @@ -378,7 +378,7 @@ In this configuration: To change strategies, you'll need to: 1. Export data from existing tenants -2. Update `database.yml` with new `postgresql_strategy` +2. Update `database.yml` by adding or removing `schema_name_pattern` 3. Create new tenant databases/schemas 4. Import data into new structure @@ -396,7 +396,7 @@ PostgreSQL has strict naming conventions for identifiers (database names and sch - Hyphens (`-`) **Additional Constraints:** -- **Maximum Length:** 63 characters total (including any database prefix) +- **Maximum Length:** 63 characters total (including any pattern prefix from `database` or `schema_name_pattern`) - **First Character:** Must be a letter or underscore (cannot start with a number or special character) - **Forward Slashes:** Not allowed in PostgreSQL identifiers diff --git a/lib/active_record/tenanted/database_adapters/postgresql.rb b/lib/active_record/tenanted/database_adapters/postgresql.rb index a6dd7832..c6a368d7 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql.rb @@ -11,11 +11,8 @@ module DatabaseAdapters # PostgreSQL adapter support for multi-tenancy # # Supports two strategies: - # - Schema-based (default): Multiple schemas within a single database - # - Database-based: Separate databases per tenant - # - # Configure strategy in database.yml: - # postgresql_strategy: schema # or "database" + # - Schema-based (preferred): Multiple schemas within a single database + # - Database-based (default): Separate databases per tenant module PostgreSQL end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/database.rb b/lib/active_record/tenanted/database_adapters/postgresql/database.rb index 1133d570..579ee92c 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/database.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/database.rb @@ -12,7 +12,6 @@ module PostgreSQL # Configuration example: # adapter: postgresql # tenanted: true - # postgresql_strategy: database # database: myapp_%{tenant} # # The adapter will: @@ -128,14 +127,14 @@ def validate_tenant_name(tenant_name) if db_config.configuration_hash.key?(:schema_search_path) raise ActiveRecord::Tenanted::ConfigurationError, "PostgreSQL database strategy does not use `schema_search_path`. " \ - "Remove this configuration, or use `postgresql_strategy: schema` " \ + "Remove this configuration, or use `schema_name_pattern` " \ "if you want schema-based multi-tenancy." end if db_config.configuration_hash.key?(:tenant_schema) raise ActiveRecord::Tenanted::ConfigurationError, "PostgreSQL database strategy does not use `tenant_schema`. " \ - "Remove this configuration, or use `postgresql_strategy: schema` " \ + "Remove this configuration, or use `schema_name_pattern` " \ "if you want schema-based multi-tenancy." end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/factory.rb b/lib/active_record/tenanted/database_adapters/postgresql/factory.rb index a550aa7e..a0a57b59 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/factory.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/factory.rb @@ -6,62 +6,21 @@ module DatabaseAdapters module PostgreSQL # Factory for creating the appropriate PostgreSQL adapter based on strategy # - # The strategy is determined by the `postgresql_strategy` configuration option: + # The strategy is determined by the presence of + # `schema_name_pattern` → "schema" strategy (colocated) + # + # Strategies: # - "schema" (default): Uses schema-based multi-tenancy # - "database": Uses database-based multi-tenancy class Factory - VALID_STRATEGIES = %w[schema database].freeze - DEFAULT_STRATEGY = "schema" - def self.new(db_config) - strategy = db_config.configuration_hash[:postgresql_strategy]&.to_s || DEFAULT_STRATEGY - - # Validate strategy at config load time - unless VALID_STRATEGIES.include?(strategy) - raise ActiveRecord::Tenanted::UnsupportedDatabaseError, - "Invalid PostgreSQL strategy: #{strategy.inspect}. " \ - "Valid options are: #{VALID_STRATEGIES.map(&:inspect).join(', ')}\n\n" \ - "Did you mean #{suggest_strategy(strategy).inspect}?" - end - - case strategy - when "schema" + # Auto-detect strategy: if schema_name_pattern is present + if db_config.configuration_hash[:schema_name_pattern] Schema.new(db_config) - when "database" + else Database.new(db_config) end end - - def self.suggest_strategy(invalid_strategy) - # Simple typo detection using Levenshtein distance - VALID_STRATEGIES.min_by { |valid| levenshtein_distance(invalid_strategy.to_s, valid) } - end - - def self.levenshtein_distance(s, t) - # Implementation of Levenshtein distance for suggestion - m = s.length - n = t.length - return m if n == 0 - return n if m == 0 - - d = Array.new(m + 1) { Array.new(n + 1) } - - (0..m).each { |i| d[i][0] = i } - (0..n).each { |j| d[0][j] = j } - - (1..n).each do |j| - (1..m).each do |i| - cost = s[i - 1] == t[j - 1] ? 0 : 1 - d[i][j] = [ - d[i - 1][j] + 1, # deletion - d[i][j - 1] + 1, # insertion - d[i - 1][j - 1] + cost, # substitution - ].min - end - end - - d[m][n] - end end end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index dbff2283..99f1ed56 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -10,15 +10,17 @@ module PostgreSQL # separate schemas within a single PostgreSQL database. This is more efficient # and aligns with PostgreSQL best practices. # - # Configuration example: + # Configuration examples: + # + # Colocated approach (recommended): # adapter: postgresql # tenanted: true - # postgresql_strategy: schema # or omit (this is the default) - # database: tenant_%{tenant} # This becomes the schema name + # database: myapp_production + # schema_name_pattern: "%{tenant}" # Creates schemas: account-123, account-456, etc. # # The adapter will: # - Connect to a single base database - # - Create/use schemas like "tenant_foo", "tenant_bar", etc. + # - Create/use schemas for tenant isolation # - Set schema_search_path to isolate tenants class Schema < Base include Colocated @@ -163,15 +165,33 @@ def database_path # Prepare tenant config hash with schema-specific settings def prepare_tenant_config_hash(config_hash, base_config, tenant_name) - schema_name = base_config.database_for(tenant_name) + # If schema_name_pattern is provided, use it for schema names + # Otherwise fall back to database pattern (original behavior) + if base_config.configuration_hash[:schema_name_pattern] + schema_name = sprintf(base_config.configuration_hash[:schema_name_pattern], tenant: tenant_name.to_s) + database_name = extract_base_database_name + else + schema_name = base_config.database_for(tenant_name) + database_name = base_config.database.gsub(/%\{tenant\}/, "tenanted") + end config_hash.merge( schema_search_path: schema_name, tenant_schema: schema_name, - database: base_config.database.gsub(/%\{tenant\}/, "tenanted") + database: database_name ) end + def identifier_for(tenant_name) + # Override base class to use schema_name_pattern if provided + if db_config.configuration_hash[:schema_name_pattern] + sprintf(db_config.configuration_hash[:schema_name_pattern], tenant: tenant_name.to_s) + else + # Fall back to base class behavior + super + end + end + private def with_base_connection(&block) # Connect to the base database (without tenant-specific schema) @@ -237,17 +257,20 @@ def base_db_config end def extract_base_database_name - # Extract base database name from the pattern - # For "test_%{tenant}" (after YAML loading) we need a consistent database name - # We'll use the pattern with "tenanted" as the suffix - # This gives us something like "test_tenanted" - db_config.database.gsub(/%\{tenant\}/, "tenanted") + # Extract base database name + # If schema_name_pattern is provided, the database name is already static + # Otherwise, replace %{tenant} with "tenanted" for the base database + if db_config.configuration_hash[:schema_name_pattern] + db_config.database + else + db_config.database.gsub(/%\{tenant\}/, "tenanted") + end end def schema_name_for(tenant_name) - # Generate schema name from tenant name using the database pattern - # For pattern like "tenant_%{tenant}", this becomes "tenant_foo" - sprintf(db_config.database, tenant: tenant_name.to_s) + # Generate schema name from tenant name + # Delegate to identifier_for for consistency + identifier_for(tenant_name) end end end diff --git a/test/scenarios/postgresql/colocated_schema/database.yml b/test/scenarios/postgresql/colocated_schema/database.yml new file mode 100644 index 00000000..84a03670 --- /dev/null +++ b/test/scenarios/postgresql/colocated_schema/database.yml @@ -0,0 +1,21 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: 20 + max_connections: 20 + username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> + password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> + host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> + +test: + primary: + <<: *default + tenanted: true + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_colocated + schema_name_pattern: "%%{tenant}" + migrations_paths: "%{db_path}/tenanted_migrations" + shared: + <<: *default + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_colocated_shared + migrations_paths: "%{db_path}/shared_migrations" diff --git a/test/scenarios/postgresql/colocated_schema/primary_record.rb b/test/scenarios/postgresql/colocated_schema/primary_record.rb new file mode 100644 index 00000000..34ed609f --- /dev/null +++ b/test/scenarios/postgresql/colocated_schema/primary_record.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class TenantedApplicationRecord < ActiveRecord::Base + self.abstract_class = true + tenanted +end + +class User < TenantedApplicationRecord +end diff --git a/test/scenarios/postgresql/colocated_schema/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/colocated_schema/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/colocated_schema/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/postgresql/primary_db/database.yml b/test/scenarios/postgresql/primary_db/database.yml index f1234069..c307b8f2 100644 --- a/test/scenarios/postgresql/primary_db/database.yml +++ b/test/scenarios/postgresql/primary_db/database.yml @@ -12,8 +12,8 @@ test: primary: <<: *default tenanted: true - postgresql_strategy: schema # Explicit for test clarity (this is the default) database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} + schema_name_pattern: "%%{tenant}" migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default diff --git a/test/scenarios/postgresql/primary_db_database_strategy/database.yml b/test/scenarios/postgresql/primary_db_database_strategy/database.yml index 5db57e73..b2d4b62e 100644 --- a/test/scenarios/postgresql/primary_db_database_strategy/database.yml +++ b/test/scenarios/postgresql/primary_db_database_strategy/database.yml @@ -12,7 +12,6 @@ test: primary: <<: *default tenanted: true - postgresql_strategy: database # Use separate databases per tenant database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: diff --git a/test/scenarios/postgresql/primary_named_db/database.yml b/test/scenarios/postgresql/primary_named_db/database.yml index cada7624..e5bbd2a4 100644 --- a/test/scenarios/postgresql/primary_named_db/database.yml +++ b/test/scenarios/postgresql/primary_named_db/database.yml @@ -12,7 +12,6 @@ test: tenanted: <<: *default tenanted: true - postgresql_strategy: schema # Explicit for test clarity (this is the default) database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: diff --git a/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml b/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml index 52c008cc..e5bbd2a4 100644 --- a/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml +++ b/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml @@ -12,7 +12,6 @@ test: tenanted: <<: *default tenanted: true - postgresql_strategy: database # Use separate databases per tenant database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" shared: diff --git a/test/scenarios/postgresql/secondary_db/database.yml b/test/scenarios/postgresql/secondary_db/database.yml index 0177b440..b50d0f51 100644 --- a/test/scenarios/postgresql/secondary_db/database.yml +++ b/test/scenarios/postgresql/secondary_db/database.yml @@ -16,7 +16,6 @@ test: tenanted: <<: *default tenanted: true - postgresql_strategy: schema # Explicit for test clarity (this is the default) database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" diff --git a/test/scenarios/postgresql/secondary_db_database_strategy/database.yml b/test/scenarios/postgresql/secondary_db_database_strategy/database.yml index 69f5f046..b50d0f51 100644 --- a/test/scenarios/postgresql/secondary_db_database_strategy/database.yml +++ b/test/scenarios/postgresql/secondary_db_database_strategy/database.yml @@ -16,7 +16,6 @@ test: tenanted: <<: *default tenanted: true - postgresql_strategy: database # Use separate databases per tenant database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} migrations_paths: "%{db_path}/tenanted_migrations" diff --git a/test/unit/database_adapters_postgresql_database_test.rb b/test/unit/database_adapters_postgresql_database_test.rb index bca938fb..249a7039 100644 --- a/test/unit/database_adapters_postgresql_database_test.rb +++ b/test/unit/database_adapters_postgresql_database_test.rb @@ -4,7 +4,7 @@ describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database do let(:db_config) do - config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "database" } + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}" } ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) end let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database.new(db_config) } @@ -16,29 +16,10 @@ end describe "validate_tenant_name" do - test "raises error if schema_search_path is configured" do - config_hash = { - adapter: "postgresql", - database: "myapp_%{tenant}", - postgresql_strategy: "database", - schema_search_path: "myapp_foo", - } - db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database.new(db_config) - - error = assert_raises(ActiveRecord::Tenanted::ConfigurationError) do - adapter.validate_tenant_name("foo") - end - - assert_match(/does not use `schema_search_path`/, error.message) - assert_match(/postgresql_strategy: schema/, error.message) - end - test "raises error if tenant_schema is configured" do config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", - postgresql_strategy: "database", tenant_schema: "myapp_foo", } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) @@ -49,7 +30,6 @@ end assert_match(/does not use `tenant_schema`/, error.message) - assert_match(/postgresql_strategy: schema/, error.message) end test "allows valid configuration without schema-specific settings" do diff --git a/test/unit/database_adapters_postgresql_factory_test.rb b/test/unit/database_adapters_postgresql_factory_test.rb index 4cf6acef..5f205e63 100644 --- a/test/unit/database_adapters_postgresql_factory_test.rb +++ b/test/unit/database_adapters_postgresql_factory_test.rb @@ -4,17 +4,8 @@ describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory do describe "strategy selection" do - test "returns Schema adapter when strategy is 'schema'" do - config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "schema" } - db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) - - assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter - end - - test "returns Database adapter when strategy is 'database'" do - config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "database" } + test "returns Database adapter when schema_name_pattern is not set" do + config_hash = { adapter: "postgresql", database: "test_%{tenant}" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) @@ -22,8 +13,8 @@ assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter end - test "returns Schema adapter when strategy is not specified (default)" do - config_hash = { adapter: "postgresql", database: "test_%{tenant}" } + test "auto-detects Schema strategy when schema_name_pattern is present" do + config_hash = { adapter: "postgresql", database: "myapp_production", schema_name_pattern: "%{tenant}" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) @@ -31,54 +22,17 @@ assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter end - test "raises error for invalid strategy" do - config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "invalid" } - db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - - error = assert_raises(ActiveRecord::Tenanted::UnsupportedDatabaseError) do - ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) - end - - assert_match(/Invalid PostgreSQL strategy/, error.message) - assert_match(/"invalid"/, error.message) - assert_match(/Valid options are/, error.message) - end - - test "suggests correct strategy for typos" do - config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: "schemas" } - db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - - error = assert_raises(ActiveRecord::Tenanted::UnsupportedDatabaseError) do - ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) - end - - assert_match(/Did you mean "schema"\?/, error.message) - end - - test "handles strategy as symbol" do - config_hash = { adapter: "postgresql", database: "test_%{tenant}", postgresql_strategy: :database } + test "works with complex schema_name_pattern" do + config_hash = { + adapter: "postgresql", + database: "rails_backend_development", + schema_name_pattern: "tenant_%{tenant}", + } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) - assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter - end - end - - describe "suggest_strategy" do - test "suggests 'schema' for 'schemas'" do - suggestion = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.suggest_strategy("schemas") - assert_equal "schema", suggestion - end - - test "suggests 'database' for 'databases'" do - suggestion = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.suggest_strategy("databases") - assert_equal "database", suggestion - end - - test "suggests closest match for completely wrong input" do - suggestion = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.suggest_strategy("xyz") - assert_includes [ "schema", "database" ], suggestion + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter end end end diff --git a/test/unit/database_adapters_postgresql_schema_test.rb b/test/unit/database_adapters_postgresql_schema_test.rb index 99fc0913..de19ef74 100644 --- a/test/unit/database_adapters_postgresql_schema_test.rb +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -4,7 +4,7 @@ describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema do let(:db_config) do - config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "schema" } + config_hash = { adapter: "postgresql", database: "myapp", schema_name_pattern: "%{tenant}" } ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) end let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) } @@ -30,6 +30,7 @@ def db_config_with_schema.configuration_hash test "adds schema-specific configuration" do base_config = Object.new def base_config.database; "myapp_%{tenant}"; end + def base_config.configuration_hash; {}; end def base_config.database_for(tenant_name) "myapp_#{tenant_name}" end @@ -45,6 +46,7 @@ def base_config.database_for(tenant_name) test "uses consistent base database name" do base_config = Object.new def base_config.database; "test_%{tenant}"; end + def base_config.configuration_hash; {}; end def base_config.database_for(tenant_name) "test_#{tenant_name}" end @@ -55,6 +57,57 @@ def base_config.database_for(tenant_name) # Base database should replace %{tenant} with "tenanted" assert_equal "test_tenanted", result[:database] end + + test "uses schema_name_pattern when provided" do + base_config = Object.new + def base_config.database; "myapp_development"; end + def base_config.configuration_hash + { schema_name_pattern: "tenant_%{tenant}" } + end + def base_config.database_for(tenant_name) + "myapp_development" + end + + db_config_with_pattern = Object.new + def db_config_with_pattern.database; "myapp_development"; end + def db_config_with_pattern.configuration_hash + { schema_name_pattern: "tenant_%{tenant}" } + end + + adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + config_hash = { tenant: "foo" } + result = adapter_with_pattern.prepare_tenant_config_hash(config_hash, base_config, "foo") + + # Schema name should use the pattern + assert_equal "tenant_foo", result[:schema_search_path] + assert_equal "tenant_foo", result[:tenant_schema] + # Database name should remain static + assert_equal "myapp_development", result[:database] + end + + test "uses static database name with schema_name_pattern" do + base_config = Object.new + def base_config.database; "rails_backend_production"; end + def base_config.configuration_hash + { schema_name_pattern: "%{tenant}" } + end + + db_config_with_pattern = Object.new + def db_config_with_pattern.database; "rails_backend_production"; end + def db_config_with_pattern.configuration_hash + { schema_name_pattern: "%{tenant}" } + end + + adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + config_hash = { tenant: "account-abc-123" } + result = adapter_with_pattern.prepare_tenant_config_hash(config_hash, base_config, "account-abc-123") + + # Schema name should be just the tenant + assert_equal "account-abc-123", result[:schema_search_path] + assert_equal "account-abc-123", result[:tenant_schema] + # Database name should remain static + assert_equal "rails_backend_production", result[:database] + end end describe "identifier_for" do @@ -62,6 +115,30 @@ def base_config.database_for(tenant_name) result = adapter.identifier_for("foo") assert_equal "myapp_foo", result end + + test "uses schema_name_pattern when provided" do + db_config_with_pattern = Object.new + def db_config_with_pattern.database; "myapp_development"; end + def db_config_with_pattern.configuration_hash + { schema_name_pattern: "tenant_%{tenant}" } + end + + adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + result = adapter_with_pattern.identifier_for("foo") + assert_equal "tenant_foo", result + end + + test "uses simple pattern with schema_name_pattern" do + db_config_with_pattern = Object.new + def db_config_with_pattern.database; "rails_backend_development"; end + def db_config_with_pattern.configuration_hash + { schema_name_pattern: "%{tenant}" } + end + + adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + result = adapter_with_pattern.identifier_for("account-abc-123") + assert_equal "account-abc-123", result + end end describe "colocated?" do @@ -74,7 +151,7 @@ def base_config.database_for(tenant_name) test "delegates to Rails DatabaseTasks.create with base database config" do # This test verifies that create_colocated_database fully integrates with Rails adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) - base_db_name = "myapp" + base_db_name = "myapp_tenanted" # Verify DatabaseTasks.create is called with the correct config ActiveRecord::Tasks::DatabaseTasks.stub :create, ->(config) do @@ -85,13 +162,34 @@ def base_config.database_for(tenant_name) adapter.create_colocated_database end end + + test "uses static database name when schema_name_pattern is provided" do + db_config_with_pattern = Object.new + def db_config_with_pattern.database; "myapp_production"; end + def db_config_with_pattern.configuration_hash + { schema_name_pattern: "%{tenant}", adapter: "postgresql" } + end + def db_config_with_pattern.env_name; "test"; end + def db_config_with_pattern.name; "primary"; end + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + + # Verify DatabaseTasks.create is called with static database name + ActiveRecord::Tasks::DatabaseTasks.stub :create, ->(config) do + assert_equal "myapp_production", config.database + assert_equal "test", config.env_name + assert_equal "postgresql", config.configuration_hash[:adapter] + end do + adapter.create_colocated_database + end + end end describe "drop_colocated_database" do test "delegates to Rails DatabaseTasks.drop with base database config" do # This test verifies that drop_colocated_database fully integrates with Rails adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) - base_db_name = "myapp" + base_db_name = "myapp_tenanted" # Verify DatabaseTasks.drop is called with the correct config ActiveRecord::Tasks::DatabaseTasks.stub :drop, ->(config) do @@ -102,5 +200,26 @@ def base_config.database_for(tenant_name) adapter.drop_colocated_database end end + + test "uses static database name when schema_name_pattern is provided" do + db_config_with_pattern = Object.new + def db_config_with_pattern.database; "myapp_production"; end + def db_config_with_pattern.configuration_hash + { schema_name_pattern: "%{tenant}", adapter: "postgresql" } + end + def db_config_with_pattern.env_name; "test"; end + def db_config_with_pattern.name; "primary"; end + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + + # Verify DatabaseTasks.drop is called with static database name + ActiveRecord::Tasks::DatabaseTasks.stub :drop, ->(config) do + assert_equal "myapp_production", config.database + assert_equal "test", config.env_name + assert_equal "postgresql", config.configuration_hash[:adapter] + end do + adapter.drop_colocated_database + end + end end end diff --git a/test/unit/database_adapters_postgresql_test.rb b/test/unit/database_adapters_postgresql_test.rb index a6191b98..a1b33bcb 100644 --- a/test/unit/database_adapters_postgresql_test.rb +++ b/test/unit/database_adapters_postgresql_test.rb @@ -11,31 +11,22 @@ describe "ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL" do describe "default adapter" do - test "creates Schema adapter by default" do - config_hash = { adapter: "postgresql", database: "myapp_%{tenant}" } + test "creates Database adapter by default" do + config_hash = { adapter: "postgresql", database: "myapp" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) - assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter end - test "creates Schema adapter when strategy is explicitly 'schema'" do - config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "schema" } + test "creates Schema adapter when schema_name_pattern is set" do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", schema_name_pattern: "%{tenant}" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter end - - test "creates Database adapter when strategy is 'database'" do - config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", postgresql_strategy: "database" } - db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) - - adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) - - assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter - end end end diff --git a/test/unit/postgresql_colocated_schema_test.rb b/test/unit/postgresql_colocated_schema_test.rb new file mode 100644 index 00000000..5751c26a --- /dev/null +++ b/test/unit/postgresql_colocated_schema_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "test_helper" + +describe "PostgreSQL Colocated Schema Strategy" do + with_scenario("postgresql/colocated_schema", :primary_record) do + describe "schema_name_pattern configuration" do + test "creates tenants with simple schema names in a static database" do + # Verify the configuration is set correctly + config = TenantedApplicationRecord.tenanted_root_config + assert_equal "%{tenant}", config.configuration_hash[:schema_name_pattern] + + # Database name should not contain %{tenant} + assert_match(/test_colocated$/, config.database) + + # Create a tenant + TenantedApplicationRecord.create_tenant("customer-abc-123") + + # Verify tenant exists + assert TenantedApplicationRecord.tenant_exist?("customer-abc-123") + + # Verify we can use the tenant + TenantedApplicationRecord.with_tenant("customer-abc-123") do + user = User.create!(email: "test@example.com") + assert_equal "customer-abc-123", user.tenant + assert_equal 1, User.count + end + end + + test "creates multiple tenants in the same database" do + # Create multiple tenants + TenantedApplicationRecord.create_tenant("tenant-1") + TenantedApplicationRecord.create_tenant("tenant-2") + TenantedApplicationRecord.create_tenant("tenant-3") + + # All tenants should exist + assert TenantedApplicationRecord.tenant_exist?("tenant-1") + assert TenantedApplicationRecord.tenant_exist?("tenant-2") + assert TenantedApplicationRecord.tenant_exist?("tenant-3") + + # Add data to each tenant + TenantedApplicationRecord.with_tenant("tenant-1") do + User.create!(email: "user1@tenant1.com") + end + + TenantedApplicationRecord.with_tenant("tenant-2") do + User.create!(email: "user1@tenant2.com") + User.create!(email: "user2@tenant2.com") + end + + TenantedApplicationRecord.with_tenant("tenant-3") do + User.create!(email: "user1@tenant3.com") + User.create!(email: "user2@tenant3.com") + User.create!(email: "user3@tenant3.com") + end + + # Verify data isolation + TenantedApplicationRecord.with_tenant("tenant-1") do + assert_equal 1, User.count + assert_equal "user1@tenant1.com", User.first.email + end + + TenantedApplicationRecord.with_tenant("tenant-2") do + assert_equal 2, User.count + end + + TenantedApplicationRecord.with_tenant("tenant-3") do + assert_equal 3, User.count + end + end + + test "handles UUID-based tenant names" do + uuid = "550e8400-e29b-41d4-a716-446655440000" + + TenantedApplicationRecord.create_tenant(uuid) + assert TenantedApplicationRecord.tenant_exist?(uuid) + + TenantedApplicationRecord.with_tenant(uuid) do + User.create!(email: "uuid@example.com") + assert_equal 1, User.count + end + end + + test "schema names match the tenant names exactly" do + tenant_name = "exact-match-test" + TenantedApplicationRecord.create_tenant(tenant_name) + + # Get the actual schema name from the database + TenantedApplicationRecord.with_tenant(tenant_name) do + schema_name = User.connection.select_value( + "SELECT current_schema()" + ) + assert_equal tenant_name, schema_name + end + end + + test "all tenants share the same base database" do + TenantedApplicationRecord.create_tenant("shared-1") + TenantedApplicationRecord.create_tenant("shared-2") + + db_name_1 = nil + db_name_2 = nil + + TenantedApplicationRecord.with_tenant("shared-1") do + db_name_1 = User.connection.select_value( + "SELECT current_database()" + ) + end + + TenantedApplicationRecord.with_tenant("shared-2") do + db_name_2 = User.connection.select_value( + "SELECT current_database()" + ) + end + + # Both tenants should be in the same database + assert_equal db_name_1, db_name_2 + assert_match(/test_colocated$/, db_name_1) + end + + test "supports tenant names with special characters" do + # PostgreSQL allows hyphens and underscores in identifiers + tenants = [ + "tenant-with-hyphens", + "tenant_with_underscores", + "tenant-123-numbers", + ] + + tenants.each do |tenant_name| + TenantedApplicationRecord.create_tenant(tenant_name) + assert TenantedApplicationRecord.tenant_exist?(tenant_name) + + TenantedApplicationRecord.with_tenant(tenant_name) do + User.create!(email: "test@#{tenant_name}.com") + assert_equal 1, User.count + end + end + end + end + end +end From 004cbb0e7fb1ee2e4a7756fd35a852c5d69f9dee Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Sun, 21 Dec 2025 14:24:59 -0600 Subject: [PATCH 59/64] Reoganize postgresql test senarios --- bin/test-integration | 4 ++-- .../postgresql/colocated_schema/database.yml | 21 ------------------- .../colocated_schema/primary_record.rb | 9 -------- .../database.yml | 4 ++-- .../primary_record.rb | 0 .../secondary_record.rb | 0 .../20250203191207_create_announcements.rb | 0 .../subtenant_record.rb | 0 .../20250203191115_create_users.rb | 0 .../database.yml | 0 .../primary_record.rb | 0 .../secondary_record.rb | 0 .../20250203191207_create_announcements.rb | 0 .../20250203191115_create_users.rb | 0 .../20250203191115_create_users.rb | 11 ---------- .../database.yml | 0 .../primary_record.rb | 0 .../secondary_record.rb | 0 .../20250203191207_create_announcements.rb | 0 .../20250203191115_create_users.rb | 0 20 files changed, 4 insertions(+), 45 deletions(-) delete mode 100644 test/scenarios/postgresql/colocated_schema/database.yml delete mode 100644 test/scenarios/postgresql/colocated_schema/primary_record.rb rename test/scenarios/postgresql/{primary_db => primary_db_schema_strategy}/database.yml (92%) rename test/scenarios/postgresql/{primary_db => primary_db_schema_strategy}/primary_record.rb (100%) rename test/scenarios/postgresql/{primary_db => primary_db_schema_strategy}/secondary_record.rb (100%) rename test/scenarios/postgresql/{primary_db => primary_db_schema_strategy}/shared_migrations/20250203191207_create_announcements.rb (100%) rename test/scenarios/postgresql/{primary_db => primary_db_schema_strategy}/subtenant_record.rb (100%) rename test/scenarios/postgresql/{colocated_schema => primary_db_schema_strategy}/tenanted_migrations/20250203191115_create_users.rb (100%) rename test/scenarios/postgresql/{primary_named_db => primary_named_db_schema_strategy}/database.yml (100%) rename test/scenarios/postgresql/{primary_named_db => primary_named_db_schema_strategy}/primary_record.rb (100%) rename test/scenarios/postgresql/{primary_named_db => primary_named_db_schema_strategy}/secondary_record.rb (100%) rename test/scenarios/postgresql/{primary_named_db => primary_named_db_schema_strategy}/shared_migrations/20250203191207_create_announcements.rb (100%) rename test/scenarios/postgresql/{primary_db => primary_named_db_schema_strategy}/tenanted_migrations/20250203191115_create_users.rb (100%) delete mode 100644 test/scenarios/postgresql/secondary_db/tenanted_migrations/20250203191115_create_users.rb rename test/scenarios/postgresql/{secondary_db => secondary_db_schema_stragegy}/database.yml (100%) rename test/scenarios/postgresql/{secondary_db => secondary_db_schema_stragegy}/primary_record.rb (100%) rename test/scenarios/postgresql/{secondary_db => secondary_db_schema_stragegy}/secondary_record.rb (100%) rename test/scenarios/postgresql/{secondary_db => secondary_db_schema_stragegy}/shared_migrations/20250203191207_create_announcements.rb (100%) rename test/scenarios/postgresql/{primary_named_db => secondary_db_schema_stragegy}/tenanted_migrations/20250203191115_create_users.rb (100%) diff --git a/bin/test-integration b/bin/test-integration index 898424ef..6f5a1dc7 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -22,8 +22,8 @@ require "debug" require "active_support" require "active_support/core_ext/object" # deep_dup -scenarios = Dir.glob("test/scenarios/*/*db/*record.rb").map do |path| - adapter, database, models = path.scan(%r{test/scenarios/(.+)/(.+db)/(.+record).rb}).flatten +scenarios = Dir.glob("test/scenarios/**/*record.rb").map do |path| + adapter, database, models = path.scan(%r{test/scenarios/(.+)/([^/]+)/(.+record).rb}).flatten { adapter: adapter, database: database, models: models } end diff --git a/test/scenarios/postgresql/colocated_schema/database.yml b/test/scenarios/postgresql/colocated_schema/database.yml deleted file mode 100644 index 84a03670..00000000 --- a/test/scenarios/postgresql/colocated_schema/database.yml +++ /dev/null @@ -1,21 +0,0 @@ -default: &default - adapter: postgresql - encoding: unicode - pool: 20 - max_connections: 20 - username: <%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %> - password: <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %> - host: <%= ENV.fetch('POSTGRES_HOST', '127.0.0.1') %> - port: <%= ENV.fetch('POSTGRES_PORT', '5432') %> - -test: - primary: - <<: *default - tenanted: true - database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_colocated - schema_name_pattern: "%%{tenant}" - migrations_paths: "%{db_path}/tenanted_migrations" - shared: - <<: *default - database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_colocated_shared - migrations_paths: "%{db_path}/shared_migrations" diff --git a/test/scenarios/postgresql/colocated_schema/primary_record.rb b/test/scenarios/postgresql/colocated_schema/primary_record.rb deleted file mode 100644 index 34ed609f..00000000 --- a/test/scenarios/postgresql/colocated_schema/primary_record.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class TenantedApplicationRecord < ActiveRecord::Base - self.abstract_class = true - tenanted -end - -class User < TenantedApplicationRecord -end diff --git a/test/scenarios/postgresql/primary_db/database.yml b/test/scenarios/postgresql/primary_db_schema_strategy/database.yml similarity index 92% rename from test/scenarios/postgresql/primary_db/database.yml rename to test/scenarios/postgresql/primary_db_schema_strategy/database.yml index c307b8f2..f0bab4d9 100644 --- a/test/scenarios/postgresql/primary_db/database.yml +++ b/test/scenarios/postgresql/primary_db_schema_strategy/database.yml @@ -12,8 +12,8 @@ test: primary: <<: *default tenanted: true - database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %>_%%{tenant} - schema_name_pattern: "%%{tenant}" + database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %> + schema_name_pattern: "%{tenant}" migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default diff --git a/test/scenarios/postgresql/primary_db/primary_record.rb b/test/scenarios/postgresql/primary_db_schema_strategy/primary_record.rb similarity index 100% rename from test/scenarios/postgresql/primary_db/primary_record.rb rename to test/scenarios/postgresql/primary_db_schema_strategy/primary_record.rb diff --git a/test/scenarios/postgresql/primary_db/secondary_record.rb b/test/scenarios/postgresql/primary_db_schema_strategy/secondary_record.rb similarity index 100% rename from test/scenarios/postgresql/primary_db/secondary_record.rb rename to test/scenarios/postgresql/primary_db_schema_strategy/secondary_record.rb diff --git a/test/scenarios/postgresql/primary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_db_schema_strategy/shared_migrations/20250203191207_create_announcements.rb similarity index 100% rename from test/scenarios/postgresql/primary_db/shared_migrations/20250203191207_create_announcements.rb rename to test/scenarios/postgresql/primary_db_schema_strategy/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/postgresql/primary_db/subtenant_record.rb b/test/scenarios/postgresql/primary_db_schema_strategy/subtenant_record.rb similarity index 100% rename from test/scenarios/postgresql/primary_db/subtenant_record.rb rename to test/scenarios/postgresql/primary_db_schema_strategy/subtenant_record.rb diff --git a/test/scenarios/postgresql/colocated_schema/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_db_schema_strategy/tenanted_migrations/20250203191115_create_users.rb similarity index 100% rename from test/scenarios/postgresql/colocated_schema/tenanted_migrations/20250203191115_create_users.rb rename to test/scenarios/postgresql/primary_db_schema_strategy/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/postgresql/primary_named_db/database.yml b/test/scenarios/postgresql/primary_named_db_schema_strategy/database.yml similarity index 100% rename from test/scenarios/postgresql/primary_named_db/database.yml rename to test/scenarios/postgresql/primary_named_db_schema_strategy/database.yml diff --git a/test/scenarios/postgresql/primary_named_db/primary_record.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/primary_record.rb similarity index 100% rename from test/scenarios/postgresql/primary_named_db/primary_record.rb rename to test/scenarios/postgresql/primary_named_db_schema_strategy/primary_record.rb diff --git a/test/scenarios/postgresql/primary_named_db/secondary_record.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/secondary_record.rb similarity index 100% rename from test/scenarios/postgresql/primary_named_db/secondary_record.rb rename to test/scenarios/postgresql/primary_named_db_schema_strategy/secondary_record.rb diff --git a/test/scenarios/postgresql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/shared_migrations/20250203191207_create_announcements.rb similarity index 100% rename from test/scenarios/postgresql/primary_named_db/shared_migrations/20250203191207_create_announcements.rb rename to test/scenarios/postgresql/primary_named_db_schema_strategy/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/postgresql/primary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/tenanted_migrations/20250203191115_create_users.rb similarity index 100% rename from test/scenarios/postgresql/primary_db/tenanted_migrations/20250203191115_create_users.rb rename to test/scenarios/postgresql/primary_named_db_schema_strategy/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/postgresql/secondary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/secondary_db/tenanted_migrations/20250203191115_create_users.rb deleted file mode 100644 index a04dbd6e..00000000 --- a/test/scenarios/postgresql/secondary_db/tenanted_migrations/20250203191115_create_users.rb +++ /dev/null @@ -1,11 +0,0 @@ -# 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/postgresql/secondary_db/database.yml b/test/scenarios/postgresql/secondary_db_schema_stragegy/database.yml similarity index 100% rename from test/scenarios/postgresql/secondary_db/database.yml rename to test/scenarios/postgresql/secondary_db_schema_stragegy/database.yml diff --git a/test/scenarios/postgresql/secondary_db/primary_record.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/primary_record.rb similarity index 100% rename from test/scenarios/postgresql/secondary_db/primary_record.rb rename to test/scenarios/postgresql/secondary_db_schema_stragegy/primary_record.rb diff --git a/test/scenarios/postgresql/secondary_db/secondary_record.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/secondary_record.rb similarity index 100% rename from test/scenarios/postgresql/secondary_db/secondary_record.rb rename to test/scenarios/postgresql/secondary_db_schema_stragegy/secondary_record.rb diff --git a/test/scenarios/postgresql/secondary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/shared_migrations/20250203191207_create_announcements.rb similarity index 100% rename from test/scenarios/postgresql/secondary_db/shared_migrations/20250203191207_create_announcements.rb rename to test/scenarios/postgresql/secondary_db_schema_stragegy/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/postgresql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/tenanted_migrations/20250203191115_create_users.rb similarity index 100% rename from test/scenarios/postgresql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb rename to test/scenarios/postgresql/secondary_db_schema_stragegy/tenanted_migrations/20250203191115_create_users.rb From 50c2545f844d55cbc0f6604e8e624fc5da187840 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Sun, 21 Dec 2025 15:58:49 -0600 Subject: [PATCH 60/64] Add validation to prevent postgresql schema_name_pattern with dynamic name --- .../database_adapters/postgresql/schema.rb | 17 +++++++ test/test_helper.rb | 2 +- ...atabase_adapters_postgresql_schema_test.rb | 45 +++++++++++++++++-- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index 99f1ed56..baca8ff6 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -25,6 +25,11 @@ module PostgreSQL class Schema < Base include Colocated + def initialize(db_config) + super + validate_configuration + end + def tenant_databases # Query for all schemas matching the pattern schema_pattern = schema_name_for("%") @@ -272,6 +277,18 @@ def schema_name_for(tenant_name) # Delegate to identifier_for for consistency identifier_for(tenant_name) end + + private + def validate_configuration + # Validate that we don't have conflicting configuration + if db_config.database.include?("%{tenant}") && db_config.configuration_hash[:schema_name_pattern] + raise ActiveRecord::Tenanted::ConfigurationError, + "Cannot specify both a dynamic database name with '%{tenant}' pattern and " \ + "schema_name_pattern for PostgreSQL schema strategy. " \ + "Use either: 1) static database name with schema_name_pattern, or " \ + "2) dynamic database name without schema_name_pattern." + end + end end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 31cb3921..e0bebb87 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -122,7 +122,7 @@ def with_db_scenario(db_scenario, &block) 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) + sprintf(erb_content, storage: storage_path, db_path: db_path, tenant: "%{tenant}") end let(:db_config) { YAML.load(db_config_yml, aliases: true) } diff --git a/test/unit/database_adapters_postgresql_schema_test.rb b/test/unit/database_adapters_postgresql_schema_test.rb index de19ef74..b6c945a7 100644 --- a/test/unit/database_adapters_postgresql_schema_test.rb +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -9,6 +9,20 @@ end let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) } + describe "initialization" do + test "raises error when both dynamic database name and schema_name_pattern are specified" do + invalid_config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", schema_name_pattern: "%{tenant}" } + invalid_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", invalid_config_hash) + + error = assert_raises(ActiveRecord::Tenanted::ConfigurationError) do + ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(invalid_db_config) + end + + expected_message = "Cannot specify both a dynamic database name with '%{tenant}' pattern and schema_name_pattern for PostgreSQL schema strategy" + assert error.message.include?(expected_message) + end + end + describe "database_path" do test "returns tenant_schema from config if present" do db_config_with_schema = Object.new @@ -22,7 +36,12 @@ def db_config_with_schema.configuration_hash end test "returns database pattern if tenant_schema not present" do - assert_equal "myapp_%{tenant}", adapter.database_path + db_config_dynamic = Object.new + def db_config_dynamic.database; "myapp_%{tenant}"; end + def db_config_dynamic.configuration_hash; {}; end + + adapter_dynamic = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_dynamic) + assert_equal "myapp_%{tenant}", adapter_dynamic.database_path end end @@ -112,7 +131,13 @@ def db_config_with_pattern.configuration_hash describe "identifier_for" do test "returns schema name for tenant" do - result = adapter.identifier_for("foo") + # Test without schema_name_pattern to use base behavior + db_config_without_pattern = Object.new + def db_config_without_pattern.database; "myapp_%{tenant}"; end + def db_config_without_pattern.configuration_hash; {}; end + + adapter_without_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_without_pattern) + result = adapter_without_pattern.identifier_for("foo") assert_equal "myapp_foo", result end @@ -150,7 +175,13 @@ def db_config_with_pattern.configuration_hash describe "create_colocated_database" do test "delegates to Rails DatabaseTasks.create with base database config" do # This test verifies that create_colocated_database fully integrates with Rails - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) + db_config_dynamic = Object.new + def db_config_dynamic.database; "myapp_%{tenant}"; end + def db_config_dynamic.configuration_hash; { adapter: "postgresql" }; end + def db_config_dynamic.env_name; "test"; end + def db_config_dynamic.name; "primary"; end + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_dynamic) base_db_name = "myapp_tenanted" # Verify DatabaseTasks.create is called with the correct config @@ -188,7 +219,13 @@ def db_config_with_pattern.name; "primary"; end describe "drop_colocated_database" do test "delegates to Rails DatabaseTasks.drop with base database config" do # This test verifies that drop_colocated_database fully integrates with Rails - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) + db_config_dynamic = Object.new + def db_config_dynamic.database; "myapp_%{tenant}"; end + def db_config_dynamic.configuration_hash; { adapter: "postgresql" }; end + def db_config_dynamic.env_name; "test"; end + def db_config_dynamic.name; "primary"; end + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_dynamic) base_db_name = "myapp_tenanted" # Verify DatabaseTasks.drop is called with the correct config From 2806399230cda8006f2dbe706fd9cb07d79cab6d Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Sun, 21 Dec 2025 13:37:01 -0600 Subject: [PATCH 61/64] Fix PostgreSQL issues from running inside a transaction + cable and tenant selector errors --- lib/active_record/tenanted/cable_connection.rb | 2 +- .../database_adapters/postgresql/database.rb | 13 +++++++++++-- .../tenanted/database_adapters/postgresql/schema.rb | 9 ++++++++- lib/active_record/tenanted/tenant_selector.rb | 2 +- .../primary_db_schema_strategy/database.yml | 2 +- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/active_record/tenanted/cable_connection.rb b/lib/active_record/tenanted/cable_connection.rb index d301ff15..4161013b 100644 --- a/lib/active_record/tenanted/cable_connection.rb +++ b/lib/active_record/tenanted/cable_connection.rb @@ -19,7 +19,7 @@ def connect private def set_current_tenant - tenant = tenant_resolver.call(request) + tenant = tenant_resolver&.call(request) return if tenant.nil? if tenant.present? && connection_class.tenant_exist?(tenant) diff --git a/lib/active_record/tenanted/database_adapters/postgresql/database.rb b/lib/active_record/tenanted/database_adapters/postgresql/database.rb index 579ee92c..86f4716d 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/database.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/database.rb @@ -74,7 +74,14 @@ def create_database create_options[:encoding] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.key?(:encoding) create_options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.key?(:collation) - connection.create_database(database_path, create_options) + # CREATE DATABASE cannot run inside a transaction block in PostgreSQL + # Force commit any existing transaction, then use raw connection + connection.commit_db_transaction if connection.transaction_open? + + encoding_clause = create_options[:encoding] ? " ENCODING '#{create_options[:encoding]}'" : "" + collation_clause = create_options[:collation] ? " LC_COLLATE '#{create_options[:collation]}'" : "" + + connection.raw_connection.exec("CREATE DATABASE #{connection.quote_table_name(database_path)}#{encoding_clause}#{collation_clause}") end end @@ -96,7 +103,9 @@ def drop_database Rails.logger.debug "Could not terminate connections for #{database_path}: #{e.message}" end - connection.execute("DROP DATABASE IF EXISTS #{db_name}") + # DROP DATABASE cannot run inside a transaction block in PostgreSQL + # Use raw connection to avoid transaction wrapping + connection.raw_connection.exec("DROP DATABASE IF EXISTS #{db_name}") end rescue ActiveRecord::NoDatabaseError, PG::Error => e # Database might not exist or other PostgreSQL error diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index baca8ff6..05d91783 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -232,7 +232,14 @@ def ensure_base_database_exists create_options[:encoding] = db_config.configuration_hash[:encoding] if db_config.configuration_hash.key?(:encoding) create_options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash.key?(:collation) - connection.create_database(base_db_name, create_options) + # CREATE DATABASE cannot run inside a transaction block in PostgreSQL + # Force commit any existing transaction, then use raw connection + connection.commit_db_transaction if connection.transaction_open? + + encoding_clause = create_options[:encoding] ? " ENCODING '#{create_options[:encoding]}'" : "" + collation_clause = create_options[:collation] ? " LC_COLLATE '#{create_options[:collation]}'" : "" + + connection.raw_connection.exec("CREATE DATABASE #{connection.quote_table_name(base_db_name)}#{encoding_clause}#{collation_clause}") end end rescue PG::Error => e diff --git a/lib/active_record/tenanted/tenant_selector.rb b/lib/active_record/tenanted/tenant_selector.rb index a980d7ed..d8b79690 100644 --- a/lib/active_record/tenanted/tenant_selector.rb +++ b/lib/active_record/tenanted/tenant_selector.rb @@ -24,7 +24,7 @@ def initialize(app) def call(env) request = ActionDispatch::Request.new(env) - tenant_name = tenant_resolver.call(request) + tenant_name = tenant_resolver&.call(request) if tenant_name.blank? # run the request without wrapping it in a tenanted context diff --git a/test/scenarios/postgresql/primary_db_schema_strategy/database.yml b/test/scenarios/postgresql/primary_db_schema_strategy/database.yml index f0bab4d9..feed0a10 100644 --- a/test/scenarios/postgresql/primary_db_schema_strategy/database.yml +++ b/test/scenarios/postgresql/primary_db_schema_strategy/database.yml @@ -13,7 +13,7 @@ test: <<: *default tenanted: true database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %> - schema_name_pattern: "%{tenant}" + schema_name_pattern: "%%{tenant}" migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default From ef50cb3b22756067c2262af2faa46a18c250a92d Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Wed, 24 Dec 2025 22:36:00 -0600 Subject: [PATCH 62/64] Add configuration for PostgreSQL maintenance DB --- GUIDE.md | 17 +++++++++++++++++ .../database_adapters/postgresql/base.rb | 4 ++++ .../database_adapters/postgresql/database.rb | 10 +++++----- .../database_adapters/postgresql/schema.rb | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index ad78ef2e..363423a7 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -434,6 +434,23 @@ production: Active Record Tenanted will reap the least-recently-used connection pools when this limit is surpassed. Developers are encouraged to tune this parameter with care, since setting it too low may lead to increased request latency due to frequently re-establishing database connections, while setting it too high may consume precious file descriptors and memory resources. +### 2.3.1 Configuring PostgreSQL Maintenance Database + +PostgreSQL requires connecting to an existing database to perform administrative operations like creating or dropping databases, listing databases, or creating schemas. By default, Active Record Tenanted uses the "postgres" database (the default PostgreSQL system database) for these maintenance operations. + +You can customize which database is used for maintenance operations by setting the `maintenance_database` parameter in `config/database.yml`: + +``` yaml +development: + primary: + adapter: postgresql + database: myapp_development + tenanted: true + maintenance_database: myapp_maintenance_development +``` + +**Note:** The maintenance database must exist and be accessible by your database user before running any tenant operations. This setting applies to both schema-based and database-based multi-tenancy strategies. + ### 2.4 Configuring the Connection Class By default, Active Record Tenanted assumes that `ApplicationRecord` is the tenanted abstract base class: diff --git a/lib/active_record/tenanted/database_adapters/postgresql/base.rb b/lib/active_record/tenanted/database_adapters/postgresql/base.rb index b036da57..feaeba63 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/base.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/base.rb @@ -100,6 +100,10 @@ def path_for(name) # For PostgreSQL, path is just the name (database or schema) name end + + def maintenance_db_name + db_config.configuration_hash[:maintenance_database] || "postgres" + end end end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/database.rb b/lib/active_record/tenanted/database_adapters/postgresql/database.rb index 86f4716d..7b868e6d 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/database.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/database.rb @@ -32,8 +32,8 @@ def tenant_databases with_maintenance_connection do |connection| # Query pg_database for databases matching pattern result = connection.execute(<<~SQL) - SELECT datname#{' '} - FROM pg_database#{' '} + SELECT datname + FROM pg_database WHERE datname LIKE '#{connection.quote_string(like_pattern)}' AND datistemplate = false ORDER BY datname @@ -115,8 +115,8 @@ def drop_database def database_exist? with_maintenance_connection do |connection| result = connection.execute(<<~SQL) - SELECT 1#{' '} - FROM pg_database#{' '} + SELECT 1 + FROM pg_database WHERE datname = '#{connection.quote_string(database_path)}' SQL result.any? @@ -155,7 +155,7 @@ def with_maintenance_connection(&block) def maintenance_config config_hash = db_config.configuration_hash.dup.merge( - database: "postgres", # Connect to PostgreSQL maintenance database + database: maintenance_db_name, # Connect to maintenance database database_tasks: false ) ActiveRecord::DatabaseConfigurations::HashConfig.new( diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index 05d91783..2e867981 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -214,7 +214,7 @@ def ensure_base_database_exists # Connect to postgres maintenance database to check/create base database maintenance_config = db_config.configuration_hash.dup.merge( - database: "postgres", + database: maintenance_db_name, database_tasks: false ) maintenance_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( From e48a820be717564273786040260d6cd4d9eeb2f9 Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Thu, 25 Dec 2025 13:26:43 -0600 Subject: [PATCH 63/64] Change from schema_name_pattern config to using schema config for all static DB names --- GUIDE.md | 42 ++-- .../database_adapters/postgresql/factory.rb | 13 +- .../database_adapters/postgresql/schema.rb | 48 +--- test/integration/test/caching_test.rb | 19 +- test/integration/test/turbo_broadcast_test.rb | 3 +- .../primary_db_schema_strategy/database.yml | 1 - test/test_helper.rb | 85 +++++++- ...tabase_adapters_postgresql_factory_test.rb | 13 +- ...atabase_adapters_postgresql_schema_test.rb | 206 ++++++------------ .../unit/database_adapters_postgresql_test.rb | 10 +- test/unit/postgresql_colocated_schema_test.rb | 15 +- 11 files changed, 210 insertions(+), 245 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 363423a7..dbf4ebaa 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -274,11 +274,11 @@ end #### 2.2.1 PostgreSQL Multi-Tenancy Strategies -PostgreSQL supports two isolation strategies, configurable via the `schema_name_pattern` option in `database.yml`. +PostgreSQL supports two isolation strategies, automatically inferred from the database name configuration. ##### Schema-Based Multi-Tenancy -Uses PostgreSQL schemas within a single database. This is the default strategy and is recommended for most use cases. +Uses PostgreSQL schemas within a single database. This is the recommended strategy for most use cases. **Configuration:** @@ -286,18 +286,17 @@ Uses PostgreSQL schemas within a single database. This is the default strategy a production: primary: adapter: postgresql + database: myapp_production # Static database name tenanted: true - database: myapp_production # Static database name - schema_name_pattern: "%{tenant}" # Dynamic schema names host: localhost ``` In this configuration: - A single PostgreSQL database named `myapp_production` is created (static name) -- Each tenant gets its own schema using the pattern: tenant IDs directly (e.g., `account-123`, `account-456`) +- Each tenant gets its own schema with the prefix `account-` (e.g., `account-tenant1`, `account-tenant2`) - The `schema_search_path` is set automatically to isolate tenants - All tables and data are stored within the tenant-specific schema -- **Auto-detection:** When `schema_name_pattern` is present +- **Auto-detection:** Automatically used when database name does NOT contain `%{tenant}` **Advantages:** - **Resource Efficient**: Single database process serves all tenants @@ -321,15 +320,16 @@ Creates separate PostgreSQL databases for each tenant. Similar to how MySQL and production: primary: adapter: postgresql - database: myapp_%{tenant} + database: "%{tenant}" tenanted: true host: localhost ``` In this configuration: -- Each tenant gets its own PostgreSQL database: `myapp_foo`, `myapp_bar`, etc. +- Each tenant gets its own PostgreSQL database: `account_foo`, `account_bar`, etc. - Each database has independent schemas, users, and settings - Complete isolation between tenants at the database level +- **Auto-detection:** Automatically used when database name contains `%{tenant}` **Advantages:** - **Stronger Isolation**: Complete database-level separation @@ -359,12 +359,13 @@ In this configuration: ##### Choosing a Strategy -**Use Schema Strategy with `schema_name_pattern` (recommended) when:** +**Use Schema Strategy (recommended) when:** - You have many tenants (dozens to thousands) - Resource efficiency is important - You're following PostgreSQL best practices - Tenants share the same configuration needs - You want simpler operations (backup, monitoring, etc.) +- Configuration: Use a static database name (e.g., `database: myapp_production`) **Use Database Strategy when:** - You have few tenants (less than 100) @@ -372,13 +373,16 @@ In this configuration: - Each tenant needs different database settings - You need to easily export individual tenant databases - You want consistency with MySQL/SQLite behavior +- Configuration: Use `%{tenant}` in database name (e.g., `database: "%{tenant}"`) ##### Migration Between Strategies To change strategies, you'll need to: 1. Export data from existing tenants -2. Update `database.yml` by adding or removing `schema_name_pattern` +2. Update `database.yml`: + - For Schema → Database: Change `database: myapp_production` to `database: "%{tenant}"` + - For Database → Schema: Change `database: "%{tenant}"` to `database: myapp_production` 3. Create new tenant databases/schemas 4. Import data into new structure @@ -396,26 +400,10 @@ PostgreSQL has strict naming conventions for identifiers (database names and sch - Hyphens (`-`) **Additional Constraints:** -- **Maximum Length:** 63 characters total (including any pattern prefix from `database` or `schema_name_pattern`) +- **Maximum Length:** 63 characters total (including the `account-` prefix for schema strategy) - **First Character:** Must be a letter or underscore (cannot start with a number or special character) - **Forward Slashes:** Not allowed in PostgreSQL identifiers -**Examples:** - -✅ **Valid tenant names:** -- `"tenant-one"` -- `"tenant_one"` -- `"tenant123"` -- `"$tenant"` -- `"_tenant"` - -❌ **Invalid tenant names:** -- `"tenant.name"` (contains dot) -- `"tenant name"` (contains space) -- `"123tenant"` (starts with number) -- `"tenant@domain"` (contains @ symbol) -- `"my-org/tenant"` (contains forward slash) - ### 2.3 Configuring `max_connection_pools` By default, Active Record Tenanted will cap the number of tenanted connection pools to 50. Setting a limit on the number of "live" connection pools at any one time provides control over the number of file descriptors used for database connections. For SQLite databases, it's also an important control on the amount of memory used. diff --git a/lib/active_record/tenanted/database_adapters/postgresql/factory.rb b/lib/active_record/tenanted/database_adapters/postgresql/factory.rb index a0a57b59..5d23e1c4 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/factory.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/factory.rb @@ -6,19 +6,20 @@ module DatabaseAdapters module PostgreSQL # Factory for creating the appropriate PostgreSQL adapter based on strategy # - # The strategy is determined by the presence of - # `schema_name_pattern` → "schema" strategy (colocated) + # The strategy is inferred from the database name: + # - If database name contains `%{tenant}` → "database" strategy + # - Otherwise → "schema" strategy (colocated) # # Strategies: # - "schema" (default): Uses schema-based multi-tenancy # - "database": Uses database-based multi-tenancy class Factory def self.new(db_config) - # Auto-detect strategy: if schema_name_pattern is present - if db_config.configuration_hash[:schema_name_pattern] - Schema.new(db_config) - else + # Auto-detect strategy: if database name contains %{tenant}, use database strategy + if db_config.database.include?("%{tenant}") Database.new(db_config) + else + Schema.new(db_config) end end end diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index 2e867981..0cfbd2d2 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -10,24 +10,24 @@ module PostgreSQL # separate schemas within a single PostgreSQL database. This is more efficient # and aligns with PostgreSQL best practices. # - # Configuration examples: + # Configuration example: # # Colocated approach (recommended): # adapter: postgresql # tenanted: true # database: myapp_production - # schema_name_pattern: "%{tenant}" # Creates schemas: account-123, account-456, etc. # # The adapter will: # - Connect to a single base database - # - Create/use schemas for tenant isolation + # - Create/use schemas for tenant isolation (using "account-%{tenant}" pattern) # - Set schema_search_path to isolate tenants class Schema < Base include Colocated + SCHEMA_NAME_PATTERN = "account-%{tenant}" + def initialize(db_config) super - validate_configuration end def tenant_databases @@ -170,15 +170,8 @@ def database_path # Prepare tenant config hash with schema-specific settings def prepare_tenant_config_hash(config_hash, base_config, tenant_name) - # If schema_name_pattern is provided, use it for schema names - # Otherwise fall back to database pattern (original behavior) - if base_config.configuration_hash[:schema_name_pattern] - schema_name = sprintf(base_config.configuration_hash[:schema_name_pattern], tenant: tenant_name.to_s) - database_name = extract_base_database_name - else - schema_name = base_config.database_for(tenant_name) - database_name = base_config.database.gsub(/%\{tenant\}/, "tenanted") - end + schema_name = sprintf(SCHEMA_NAME_PATTERN, tenant: tenant_name.to_s) + database_name = base_config.database config_hash.merge( schema_search_path: schema_name, @@ -188,13 +181,7 @@ def prepare_tenant_config_hash(config_hash, base_config, tenant_name) end def identifier_for(tenant_name) - # Override base class to use schema_name_pattern if provided - if db_config.configuration_hash[:schema_name_pattern] - sprintf(db_config.configuration_hash[:schema_name_pattern], tenant: tenant_name.to_s) - else - # Fall back to base class behavior - super - end + sprintf(SCHEMA_NAME_PATTERN, tenant: tenant_name.to_s) end private @@ -269,14 +256,7 @@ def base_db_config end def extract_base_database_name - # Extract base database name - # If schema_name_pattern is provided, the database name is already static - # Otherwise, replace %{tenant} with "tenanted" for the base database - if db_config.configuration_hash[:schema_name_pattern] - db_config.database - else - db_config.database.gsub(/%\{tenant\}/, "tenanted") - end + db_config.database end def schema_name_for(tenant_name) @@ -284,18 +264,6 @@ def schema_name_for(tenant_name) # Delegate to identifier_for for consistency identifier_for(tenant_name) end - - private - def validate_configuration - # Validate that we don't have conflicting configuration - if db_config.database.include?("%{tenant}") && db_config.configuration_hash[:schema_name_pattern] - raise ActiveRecord::Tenanted::ConfigurationError, - "Cannot specify both a dynamic database name with '%{tenant}' pattern and " \ - "schema_name_pattern for PostgreSQL schema strategy. " \ - "Use either: 1) static database name with schema_name_pattern, or " \ - "2) dynamic database name without schema_name_pattern." - end - end end end end diff --git a/test/integration/test/caching_test.rb b/test/integration/test/caching_test.rb index d78b6a14..c209cb5a 100644 --- a/test/integration/test/caching_test.rb +++ b/test/integration/test/caching_test.rb @@ -12,12 +12,9 @@ class TestCaching < ActionDispatch::IntegrationTest Note.create!(title: "tenant 2 note", body: "Four score and twenty years ago.") end - assert_equal(1, note1.id) - assert_equal(1, note2.id) - - # get the tenant 1 note 1, generating a fragment cache + # get the tenant 1 note, generating a fragment cache integration_session.host = "#{tenant1}.example.com" - get note_path(id: 1) + get note_path(note1) assert_response :ok page1a = @response.body @@ -25,21 +22,21 @@ class TestCaching < ActionDispatch::IntegrationTest # for testing the cache. assert_includes(page1a, "Random:") - # get the tenant 2 note 1, which should NOT clobber the tenant 1 note 1 cache + # get the tenant 2 note, which should NOT clobber the tenant 1 note cache integration_session.host = "#{tenant2}.example.com" - get note_path(id: 1) + get note_path(note2) assert_response :ok page2a = @response.body - # let's re-fetch tenant 1 note 1 to see if the fragment was cached correctly + # let's re-fetch tenant 1 note to see if the fragment was cached correctly integration_session.host = "#{tenant1}.example.com" - get note_path(id: 1) + get note_path(note1) assert_response :ok page1b = @response.body - # same for tenant 2 note 1 + # same for tenant 2 note integration_session.host = "#{tenant2}.example.com" - get note_path(id: 1) + get note_path(note2) assert_response :ok page2b = @response.body diff --git a/test/integration/test/turbo_broadcast_test.rb b/test/integration/test/turbo_broadcast_test.rb index 8a3b772d..ed2f1cf8 100644 --- a/test/integration/test/turbo_broadcast_test.rb +++ b/test/integration/test/turbo_broadcast_test.rb @@ -6,9 +6,8 @@ class TestTurboBroadcast < ApplicationSystemTestCase note1 = Note.create!(title: "Tenant-1", body: "note 1 version 1") note2 = ApplicationRecord.create_tenant(tenant2) do - Note.create!(title: "Tenant-2", body: "note 2 version 1", id: note1.id) + Note.create!(title: "Tenant-2", body: "note 2 version 1") end - assert_equal(note1.id, note2.id) visit note_url(note1) assert_text("note 1 version 1") diff --git a/test/scenarios/postgresql/primary_db_schema_strategy/database.yml b/test/scenarios/postgresql/primary_db_schema_strategy/database.yml index feed0a10..03f2469f 100644 --- a/test/scenarios/postgresql/primary_db_schema_strategy/database.yml +++ b/test/scenarios/postgresql/primary_db_schema_strategy/database.yml @@ -13,7 +13,6 @@ test: <<: *default tenanted: true database: <%= ENV.fetch('POSTGRES_UNIQUE_PREFIX', 'test') %> - schema_name_pattern: "%%{tenant}" migrations_paths: "%{db_path}/tenanted_migrations" shared: <<: *default diff --git a/test/test_helper.rb b/test/test_helper.rb index e0bebb87..1517884c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,6 +5,9 @@ ENV["ARTENANT_SCHEMA_DUMP"] = "t" # we don't normally dump schemas outside of development ENV["VERBOSE"] = "false" # suppress database task output +# Run cleanup at test suite start - defined later in ActiveRecord::Tenanted::TestCase +# We can't call it yet since the class isn't defined, so we'll do it after Rails loads + require "rails" require "rails/test_help" # should be before active_record is loaded to avoid schema/fixture setup @@ -44,6 +47,75 @@ class TestCase < ActiveSupport::TestCase extend Minitest::Spec::DSL class << self + # Shared helper to clean up PostgreSQL test databases and schemas + def cleanup_postgresql_databases_and_schemas(skip_shared: false) + return unless defined?(PG) + + prefix = ENV.fetch("POSTGRES_UNIQUE_PREFIX", "test") + + begin + require "pg" + conn = PG.connect( + host: ENV.fetch("POSTGRES_HOST", "127.0.0.1"), + port: ENV.fetch("POSTGRES_PORT", "5432").to_i, + dbname: "postgres", + user: ENV.fetch("POSTGRES_USERNAME", "postgres"), + password: ENV.fetch("POSTGRES_PASSWORD", "postgres") + ) + + # Find all databases starting with the test prefix OR integration test prefix (t) + result = conn.exec( + "SELECT datname FROM pg_database WHERE (datname LIKE '#{prefix}%' OR datname ~ '^t[0-9]+') AND datistemplate = false" + ) + + result.each do |row| + db_name = row["datname"] + next if skip_shared && db_name == "#{prefix}_shared" + + begin + conn.exec_params( + "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = $1 AND pid <> pg_backend_pid()", + [ db_name ] + ) + conn.exec("DROP DATABASE IF EXISTS #{conn.escape_identifier(db_name)}") + rescue => e + # Ignore errors + end + end + + # Clean up schemas in the base database + base_db = prefix + if conn.exec_params("SELECT 1 FROM pg_database WHERE datname = $1", [ base_db ]).ntuples > 0 + conn.close + conn = PG.connect( + host: ENV.fetch("POSTGRES_HOST", "127.0.0.1"), + port: ENV.fetch("POSTGRES_PORT", "5432").to_i, + dbname: base_db, + user: ENV.fetch("POSTGRES_USERNAME", "postgres"), + password: ENV.fetch("POSTGRES_PASSWORD", "postgres") + ) + + schemas_result = conn.exec_params( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE $1", + [ "account-%" ] + ) + + schemas_result.each do |row| + schema_name = row["schema_name"] + begin + conn.exec("DROP SCHEMA IF EXISTS #{conn.escape_identifier(schema_name)} CASCADE") + rescue => e + # Ignore + end + end + end + + conn.close if conn + rescue => e + # Continue if PostgreSQL isn't available + end + end + # When used with Minitest::Spec's `describe`, ActiveSupport::Testing's `test` creates methods # that may be inherited by subsequent describe blocks and run multiple times. Warn us if this # happens. (Note that Minitest::Spec's `it` doesn't do this.) @@ -149,6 +221,7 @@ def with_db_scenario(db_scenario, &block) teardown do drop_shared_databases drop_tentant_databases + cleanup_postgresql_test_databases if defined?(PG) && db_adapter == "postgresql" ActiveRecord::Migration.verbose = @migration_verbose_was ActiveRecord::Base.configurations = @old_configurations ActiveRecord::Tasks::DatabaseTasks.db_dir = @old_db_dir @@ -237,7 +310,9 @@ def base_config end def with_schema_dump_file - Dir.glob("test/scenarios/*/schema.rb").each do |schema_file| + # Only copy the schema file for the current adapter to avoid conflicts + schema_file = File.join("test", "scenarios", db_adapter, "schema.rb") + if File.exist?(schema_file) FileUtils.cp schema_file, ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(base_config) end @@ -281,6 +356,11 @@ def capture_rails_log end private + def cleanup_postgresql_test_databases + # Skip shared database during per-test cleanup (it gets dropped in drop_shared_databases) + self.class.cleanup_postgresql_databases_and_schemas(skip_shared: true) + end + def drop_shared_databases shared_configs = all_configs.reject { |c| c.configuration_hash[:tenanted] || c.database.blank? } return if shared_configs.empty? @@ -343,3 +423,6 @@ def clear_connected_to_stack # make TestCase the default Minitest::Spec.register_spec_type(//, ActiveRecord::Tenanted::TestCase) + +# Clean up PostgreSQL test databases at the start of the test suite +ActiveRecord::Tenanted::TestCase.cleanup_postgresql_databases_and_schemas diff --git a/test/unit/database_adapters_postgresql_factory_test.rb b/test/unit/database_adapters_postgresql_factory_test.rb index 5f205e63..180d0d7f 100644 --- a/test/unit/database_adapters_postgresql_factory_test.rb +++ b/test/unit/database_adapters_postgresql_factory_test.rb @@ -4,7 +4,7 @@ describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory do describe "strategy selection" do - test "returns Database adapter when schema_name_pattern is not set" do + test "returns Database adapter when database name contains %{tenant}" do config_hash = { adapter: "postgresql", database: "test_%{tenant}" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) @@ -13,8 +13,8 @@ assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter end - test "auto-detects Schema strategy when schema_name_pattern is present" do - config_hash = { adapter: "postgresql", database: "myapp_production", schema_name_pattern: "%{tenant}" } + test "auto-detects Schema strategy when database name is static" do + config_hash = { adapter: "postgresql", database: "myapp_production" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) @@ -22,17 +22,16 @@ assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter end - test "works with complex schema_name_pattern" do + test "returns Database adapter with just %{tenant} as database name" do config_hash = { adapter: "postgresql", - database: "rails_backend_development", - schema_name_pattern: "tenant_%{tenant}", + database: "%{tenant}", } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) - assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter end end end diff --git a/test/unit/database_adapters_postgresql_schema_test.rb b/test/unit/database_adapters_postgresql_schema_test.rb index b6c945a7..71285bd7 100644 --- a/test/unit/database_adapters_postgresql_schema_test.rb +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -4,25 +4,11 @@ describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema do let(:db_config) do - config_hash = { adapter: "postgresql", database: "myapp", schema_name_pattern: "%{tenant}" } + config_hash = { adapter: "postgresql", database: "myapp" } ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) end let(:adapter) { ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config) } - describe "initialization" do - test "raises error when both dynamic database name and schema_name_pattern are specified" do - invalid_config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", schema_name_pattern: "%{tenant}" } - invalid_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", invalid_config_hash) - - error = assert_raises(ActiveRecord::Tenanted::ConfigurationError) do - ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(invalid_db_config) - end - - expected_message = "Cannot specify both a dynamic database name with '%{tenant}' pattern and schema_name_pattern for PostgreSQL schema strategy" - assert error.message.include?(expected_message) - end - end - describe "database_path" do test "returns tenant_schema from config if present" do db_config_with_schema = Object.new @@ -46,123 +32,69 @@ def db_config_dynamic.configuration_hash; {}; end end describe "prepare_tenant_config_hash" do - test "adds schema-specific configuration" do + test "adds schema-specific configuration with account- prefix" do base_config = Object.new - def base_config.database; "myapp_%{tenant}"; end + def base_config.database; "myapp_development"; end def base_config.configuration_hash; {}; end - def base_config.database_for(tenant_name) - "myapp_#{tenant_name}" - end - config_hash = { tenant: "foo", database: "myapp_foo" } + config_hash = { tenant: "foo", database: "myapp_development" } result = adapter.prepare_tenant_config_hash(config_hash, base_config, "foo") - assert_equal "myapp_foo", result[:schema_search_path] - assert_equal "myapp_foo", result[:tenant_schema] - assert_equal "myapp_tenanted", result[:database] + assert_equal "account-foo", result[:schema_search_path] + assert_equal "account-foo", result[:tenant_schema] + assert_equal "myapp_development", result[:database] end - test "uses consistent base database name" do + test "uses static database name" do base_config = Object.new - def base_config.database; "test_%{tenant}"; end + def base_config.database; "rails_backend_production"; end def base_config.configuration_hash; {}; end - def base_config.database_for(tenant_name) - "test_#{tenant_name}" - end config_hash = { tenant: "bar" } result = adapter.prepare_tenant_config_hash(config_hash, base_config, "bar") - # Base database should replace %{tenant} with "tenanted" - assert_equal "test_tenanted", result[:database] - end - - test "uses schema_name_pattern when provided" do - base_config = Object.new - def base_config.database; "myapp_development"; end - def base_config.configuration_hash - { schema_name_pattern: "tenant_%{tenant}" } - end - def base_config.database_for(tenant_name) - "myapp_development" - end - - db_config_with_pattern = Object.new - def db_config_with_pattern.database; "myapp_development"; end - def db_config_with_pattern.configuration_hash - { schema_name_pattern: "tenant_%{tenant}" } - end - - adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) - config_hash = { tenant: "foo" } - result = adapter_with_pattern.prepare_tenant_config_hash(config_hash, base_config, "foo") - - # Schema name should use the pattern - assert_equal "tenant_foo", result[:schema_search_path] - assert_equal "tenant_foo", result[:tenant_schema] + # Schema name should use account- prefix + assert_equal "account-bar", result[:schema_search_path] + assert_equal "account-bar", result[:tenant_schema] # Database name should remain static - assert_equal "myapp_development", result[:database] + assert_equal "rails_backend_production", result[:database] end - test "uses static database name with schema_name_pattern" do + test "creates schema name with account- prefix for complex tenant" do base_config = Object.new - def base_config.database; "rails_backend_production"; end - def base_config.configuration_hash - { schema_name_pattern: "%{tenant}" } - end - - db_config_with_pattern = Object.new - def db_config_with_pattern.database; "rails_backend_production"; end - def db_config_with_pattern.configuration_hash - { schema_name_pattern: "%{tenant}" } - end + def base_config.database; "myapp_production"; end + def base_config.configuration_hash; {}; end - adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) - config_hash = { tenant: "account-abc-123" } - result = adapter_with_pattern.prepare_tenant_config_hash(config_hash, base_config, "account-abc-123") + config_hash = { tenant: "abc123" } + result = adapter.prepare_tenant_config_hash(config_hash, base_config, "abc123") - # Schema name should be just the tenant - assert_equal "account-abc-123", result[:schema_search_path] - assert_equal "account-abc-123", result[:tenant_schema] + # Schema name should use account- prefix + assert_equal "account-abc123", result[:schema_search_path] + assert_equal "account-abc123", result[:tenant_schema] # Database name should remain static - assert_equal "rails_backend_production", result[:database] + assert_equal "myapp_production", result[:database] end end describe "identifier_for" do - test "returns schema name for tenant" do - # Test without schema_name_pattern to use base behavior - db_config_without_pattern = Object.new - def db_config_without_pattern.database; "myapp_%{tenant}"; end - def db_config_without_pattern.configuration_hash; {}; end - - adapter_without_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_without_pattern) - result = adapter_without_pattern.identifier_for("foo") - assert_equal "myapp_foo", result + test "returns schema name with account- prefix" do + db_config_static = Object.new + def db_config_static.database; "myapp_development"; end + def db_config_static.configuration_hash; {}; end + + adapter_static = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_static) + result = adapter_static.identifier_for("foo") + assert_equal "account-foo", result end - test "uses schema_name_pattern when provided" do - db_config_with_pattern = Object.new - def db_config_with_pattern.database; "myapp_development"; end - def db_config_with_pattern.configuration_hash - { schema_name_pattern: "tenant_%{tenant}" } - end - - adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) - result = adapter_with_pattern.identifier_for("foo") - assert_equal "tenant_foo", result - end - - test "uses simple pattern with schema_name_pattern" do - db_config_with_pattern = Object.new - def db_config_with_pattern.database; "rails_backend_development"; end - def db_config_with_pattern.configuration_hash - { schema_name_pattern: "%{tenant}" } - end + test "uses account- prefix for complex tenant names" do + db_config_static = Object.new + def db_config_static.database; "myapp_development"; end + def db_config_static.configuration_hash; {}; end - adapter_with_pattern = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) - result = adapter_with_pattern.identifier_for("account-abc-123") - assert_equal "account-abc-123", result + adapter_static = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_static) + result = adapter_static.identifier_for("abc123") + assert_equal "account-abc123", result end end @@ -173,16 +105,16 @@ def db_config_with_pattern.configuration_hash end describe "create_colocated_database" do - test "delegates to Rails DatabaseTasks.create with base database config" do + test "delegates to Rails DatabaseTasks.create with static database config" do # This test verifies that create_colocated_database fully integrates with Rails - db_config_dynamic = Object.new - def db_config_dynamic.database; "myapp_%{tenant}"; end - def db_config_dynamic.configuration_hash; { adapter: "postgresql" }; end - def db_config_dynamic.env_name; "test"; end - def db_config_dynamic.name; "primary"; end + db_config_static = Object.new + def db_config_static.database; "myapp_development"; end + def db_config_static.configuration_hash; { adapter: "postgresql" }; end + def db_config_static.env_name; "test"; end + def db_config_static.name; "primary"; end - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_dynamic) - base_db_name = "myapp_tenanted" + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_static) + base_db_name = "myapp_development" # Verify DatabaseTasks.create is called with the correct config ActiveRecord::Tasks::DatabaseTasks.stub :create, ->(config) do @@ -194,16 +126,16 @@ def db_config_dynamic.name; "primary"; end end end - test "uses static database name when schema_name_pattern is provided" do - db_config_with_pattern = Object.new - def db_config_with_pattern.database; "myapp_production"; end - def db_config_with_pattern.configuration_hash - { schema_name_pattern: "%{tenant}", adapter: "postgresql" } + test "uses static database name" do + db_config_static = Object.new + def db_config_static.database; "myapp_production"; end + def db_config_static.configuration_hash + { adapter: "postgresql" } end - def db_config_with_pattern.env_name; "test"; end - def db_config_with_pattern.name; "primary"; end + def db_config_static.env_name; "test"; end + def db_config_static.name; "primary"; end - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_static) # Verify DatabaseTasks.create is called with static database name ActiveRecord::Tasks::DatabaseTasks.stub :create, ->(config) do @@ -217,16 +149,16 @@ def db_config_with_pattern.name; "primary"; end end describe "drop_colocated_database" do - test "delegates to Rails DatabaseTasks.drop with base database config" do + test "delegates to Rails DatabaseTasks.drop with static database config" do # This test verifies that drop_colocated_database fully integrates with Rails - db_config_dynamic = Object.new - def db_config_dynamic.database; "myapp_%{tenant}"; end - def db_config_dynamic.configuration_hash; { adapter: "postgresql" }; end - def db_config_dynamic.env_name; "test"; end - def db_config_dynamic.name; "primary"; end + db_config_static = Object.new + def db_config_static.database; "myapp_development"; end + def db_config_static.configuration_hash; { adapter: "postgresql" }; end + def db_config_static.env_name; "test"; end + def db_config_static.name; "primary"; end - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_dynamic) - base_db_name = "myapp_tenanted" + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_static) + base_db_name = "myapp_development" # Verify DatabaseTasks.drop is called with the correct config ActiveRecord::Tasks::DatabaseTasks.stub :drop, ->(config) do @@ -238,16 +170,16 @@ def db_config_dynamic.name; "primary"; end end end - test "uses static database name when schema_name_pattern is provided" do - db_config_with_pattern = Object.new - def db_config_with_pattern.database; "myapp_production"; end - def db_config_with_pattern.configuration_hash - { schema_name_pattern: "%{tenant}", adapter: "postgresql" } + test "uses static database name" do + db_config_static = Object.new + def db_config_static.database; "myapp_production"; end + def db_config_static.configuration_hash + { adapter: "postgresql" } end - def db_config_with_pattern.env_name; "test"; end - def db_config_with_pattern.name; "primary"; end + def db_config_static.env_name; "test"; end + def db_config_static.name; "primary"; end - adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_with_pattern) + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_static) # Verify DatabaseTasks.drop is called with static database name ActiveRecord::Tasks::DatabaseTasks.stub :drop, ->(config) do diff --git a/test/unit/database_adapters_postgresql_test.rb b/test/unit/database_adapters_postgresql_test.rb index a1b33bcb..933a0c05 100644 --- a/test/unit/database_adapters_postgresql_test.rb +++ b/test/unit/database_adapters_postgresql_test.rb @@ -11,22 +11,22 @@ describe "ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL" do describe "default adapter" do - test "creates Database adapter by default" do + test "creates Schema adapter for static database name" do config_hash = { adapter: "postgresql", database: "myapp" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) - assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter end - test "creates Schema adapter when schema_name_pattern is set" do - config_hash = { adapter: "postgresql", database: "myapp_%{tenant}", schema_name_pattern: "%{tenant}" } + test "creates Database adapter when database name contains %{tenant}" do + config_hash = { adapter: "postgresql", database: "myapp_%{tenant}" } db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", "primary", config_hash) adapter = ActiveRecord::Tenanted::DatabaseAdapter.new(db_config) - assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter end end end diff --git a/test/unit/postgresql_colocated_schema_test.rb b/test/unit/postgresql_colocated_schema_test.rb index 5751c26a..7441a4c0 100644 --- a/test/unit/postgresql_colocated_schema_test.rb +++ b/test/unit/postgresql_colocated_schema_test.rb @@ -3,15 +3,14 @@ require "test_helper" describe "PostgreSQL Colocated Schema Strategy" do - with_scenario("postgresql/colocated_schema", :primary_record) do - describe "schema_name_pattern configuration" do - test "creates tenants with simple schema names in a static database" do + with_scenario("postgresql/primary_db_schema_strategy", :primary_record) do + describe "account- schema pattern" do + test "creates tenants with account- prefixed schema names in a static database" do # Verify the configuration is set correctly config = TenantedApplicationRecord.tenanted_root_config - assert_equal "%{tenant}", config.configuration_hash[:schema_name_pattern] # Database name should not contain %{tenant} - assert_match(/test_colocated$/, config.database) + assert_not config.database.include?("%{tenant}") # Create a tenant TenantedApplicationRecord.create_tenant("customer-abc-123") @@ -81,7 +80,7 @@ end end - test "schema names match the tenant names exactly" do + test "schema names are prefixed with account-" do tenant_name = "exact-match-test" TenantedApplicationRecord.create_tenant(tenant_name) @@ -90,7 +89,7 @@ schema_name = User.connection.select_value( "SELECT current_schema()" ) - assert_equal tenant_name, schema_name + assert_equal "account-#{tenant_name}", schema_name end end @@ -115,7 +114,7 @@ # Both tenants should be in the same database assert_equal db_name_1, db_name_2 - assert_match(/test_colocated$/, db_name_1) + assert_not db_name_1.include?("%{tenant}") end test "supports tenant names with special characters" do From 112e752af16a631b92b426d96e7d208c143b054d Mon Sep 17 00:00:00 2001 From: Ben Sullivan Date: Thu, 25 Dec 2025 15:53:07 -0600 Subject: [PATCH 64/64] Remove double 'acount-' tagging and default schema tenant in colocated DBs --- .../tenanted/database_adapters/postgresql/schema.rb | 8 +++----- .../tenanted/database_configurations/base_config.rb | 3 +++ .../tenanted/database_configurations/tenant_config.rb | 8 +++++++- lib/active_record/tenanted/database_tasks.rb | 8 +++++++- test/unit/database_adapters_postgresql_schema_test.rb | 11 ++++++++--- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb index 0cfbd2d2..53d3226c 100644 --- a/lib/active_record/tenanted/database_adapters/postgresql/schema.rb +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -19,13 +19,11 @@ module PostgreSQL # # The adapter will: # - Connect to a single base database - # - Create/use schemas for tenant isolation (using "account-%{tenant}" pattern) + # - Create/use schemas for tenant isolation # - Set schema_search_path to isolate tenants class Schema < Base include Colocated - SCHEMA_NAME_PATTERN = "account-%{tenant}" - def initialize(db_config) super end @@ -170,7 +168,7 @@ def database_path # Prepare tenant config hash with schema-specific settings def prepare_tenant_config_hash(config_hash, base_config, tenant_name) - schema_name = sprintf(SCHEMA_NAME_PATTERN, tenant: tenant_name.to_s) + schema_name = identifier_for(tenant_name) database_name = base_config.database config_hash.merge( @@ -181,7 +179,7 @@ def prepare_tenant_config_hash(config_hash, base_config, tenant_name) end def identifier_for(tenant_name) - sprintf(SCHEMA_NAME_PATTERN, tenant: tenant_name.to_s) + sprintf("%{tenant}", tenant: tenant_name.to_s) end private diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 7d9cdc9b..8e93abd2 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -65,6 +65,9 @@ def new_tenant_config(tenant_name) hash[:tenanted_config_name] = name new_host = host_for(tenant_name) hash[:host] = new_host if new_host + + # Store the adapter class name so tenant configs use the same adapter type + hash[:tenanted_adapter_class] = config_adapter.class.name end Tenanted::DatabaseConfigurations::TenantConfig.new(env_name, config_name, config_hash) end diff --git a/lib/active_record/tenanted/database_configurations/tenant_config.rb b/lib/active_record/tenanted/database_configurations/tenant_config.rb index 27bcef99..82a6bff3 100644 --- a/lib/active_record/tenanted/database_configurations/tenant_config.rb +++ b/lib/active_record/tenanted/database_configurations/tenant_config.rb @@ -14,7 +14,13 @@ def tenant end def config_adapter - @config_adapter ||= ActiveRecord::Tenanted::DatabaseAdapter.new(self) + # Use the stored adapter class if available (set by BaseConfig#new_tenant_config) + # This ensures tenant configs use the same adapter type as their base config + @config_adapter ||= if configuration_hash[:tenanted_adapter_class] + configuration_hash[:tenanted_adapter_class].constantize.new(self) + else + ActiveRecord::Tenanted::DatabaseAdapter.new(self) + end end def new_connection diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index fce50823..7c74240d 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -59,7 +59,13 @@ def drop_tenant(tenant = set_current_tenant) end def tenants - config.tenants.presence || [ get_default_tenant ].compact + # For colocated adapters, exclude the default tenant from the list + # since all tenants share the same database/infrastructure + if adapter_colocated? + config.tenants.presence || [] + else + config.tenants.presence || [ get_default_tenant ].compact + end end def get_default_tenant diff --git a/test/unit/database_adapters_postgresql_schema_test.rb b/test/unit/database_adapters_postgresql_schema_test.rb index 71285bd7..b3d7f854 100644 --- a/test/unit/database_adapters_postgresql_schema_test.rb +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -21,13 +21,18 @@ def db_config_with_schema.configuration_hash assert_equal "myapp_foo", adapter.database_path end - test "returns database pattern if tenant_schema not present" do + test "raises error if tenant_schema not present" do db_config_dynamic = Object.new - def db_config_dynamic.database; "myapp_%{tenant}"; end + def db_config_dynamic.database; "myapp_development"; end def db_config_dynamic.configuration_hash; {}; end adapter_dynamic = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_dynamic) - assert_equal "myapp_%{tenant}", adapter_dynamic.database_path + + error = assert_raises(ActiveRecord::Tenanted::NoTenantError) do + adapter_dynamic.database_path + end + + assert_match(/tenant_schema not set/, error.message) end end