diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b1907b2..e4ff2897 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,25 @@ jobs: ruby-version: "3.4" bundler-cache: true - run: bin/test-unit + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: "3306" 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 +74,6 @@ jobs: ruby-version: "3.4" bundler-cache: true - run: bin/test-integration + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: "3306" 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! 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/bin/test-integration b/bin/test-integration index f3d896a0..635445c3 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -17,12 +17,14 @@ 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/*db/*record.rb").map do |path| - database, models = path.scan(%r{test/scenarios/(.+db)/(.+record).rb}).flatten - { database: database, models: models } +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 COLOR_FG_BLUE = "\e[0;34m" @@ -49,13 +51,19 @@ 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) # generate database file - database_file = File.join(GEM_PATH, "test/scenarios/#{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 = File.join(GEM_PATH, "test/scenarios/#{scenario[:adapter]}/#{scenario[:database]}/database.yml") + 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| hash["database"] = hash["database"].sub("/test/", "/development/") @@ -63,14 +71,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")) @@ -82,20 +90,21 @@ def run_scenario(scenario) Bundler.with_original_env do run_cmd(scenario, "bundle check || bundle install") - # set up the database and schema files - run_cmd(scenario, "bin/rails db:prepare") + 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 + + 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 - 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 in parallel half the time env = if rand(2).zero? { "PARALLEL_WORKERS" => "2" } 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/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 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..a0723716 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters # :nodoc: + class MySQL + attr_reader :db_config + + def initialize(db_config) + @db_config = db_config + end + + 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("(.+)"), "(.+)") + "$") + + begin + with_anonymous_connection 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 + [] + end + 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?("%}") + + 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 + end + + def create_database + 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) + end + + connection.create_database(database_path, create_options) + end + end + + def drop_database + with_anonymous_connection do |connection| + connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(database_path)}") + end + end + + def database_exist? + with_anonymous_connection do |connection| + result = connection.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) + 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 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, + database_tasks: false + ) + ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + "_tmp_#{db_config.name}", + configuration_hash + ) + 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..608764a7 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -37,7 +37,13 @@ def database_for(tenant_name) end def tenants - config_adapter.tenant_databases + all_databases = ActiveRecord::Base.configurations.configs_for(env_name: env_name) + untenanted = all_databases.reject { |c| c.configuration_hash[:tenanted] }.filter_map(&:database) + + config_adapter.tenant_databases.reject do |tenant_name| + database = database_for(tenant_name) + untenanted.include?(database) + end end def new_tenant_config(tenant_name) @@ -46,6 +52,8 @@ def new_tenant_config(tenant_name) hash[:tenant] = tenant_name hash[:database] = database_for(tenant_name) hash[:tenanted_config_name] = name + 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 @@ -60,6 +68,13 @@ 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) + # Only override host if it contains a tenant template + return nil unless host&.include?("%{tenant}") + sprintf(host, tenant: tenant_name) + end end end end diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index 55201b14..880c34ac 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -81,20 +81,30 @@ 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 - # initialize_database - unless pool.schema_migration.table_exists? + begin + database_already_initialized = pool.schema_migration.table_exists? + rescue ActiveRecord::NoDatabaseError + database_already_initialized = false + end + + 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 migrated = false + if pool.migration_context.pending_migration_versions.present? pool.migration_context.migrate(nil) pool.schema_cache.clear! @@ -103,7 +113,7 @@ def migrate(config) # dump the schema and schema cache if Rails.env.development? || ENV["ARTENANT_SCHEMA_DUMP"].present? - if migrated + if migrated || !database_already_initialized ActiveRecord::Tasks::DatabaseTasks.dump_schema(config) end diff --git a/test/integration/test/turbo_broadcast_test.rb b/test/integration/test/turbo_broadcast_test.rb index cf7f31af..8a3b772d 100644 --- a/test/integration/test/turbo_broadcast_test.rb +++ b/test/integration/test/turbo_broadcast_test.rb @@ -13,6 +13,8 @@ class TestTurboBroadcast < ApplicationSystemTestCase visit note_url(note1) assert_text("note 1 version 1") + wait_for_turbo_stream_connected + note1.update!(body: "note 1 version 2") assert_text("note 1 version 2") @@ -22,4 +24,13 @@ class TestTurboBroadcast < ApplicationSystemTestCase assert_no_text("note 2 version 2") assert_text("note 1 version 2") end + + private + # 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 diff --git a/test/scenarios/20250213005959_add_age_to_users.rb b/test/scenarios/mysql/20250213005959_add_age_to_users.rb similarity index 100% rename from test/scenarios/20250213005959_add_age_to_users.rb rename to test/scenarios/mysql/20250213005959_add_age_to_users.rb diff --git a/test/scenarios/20250830152220_create_posts.rb b/test/scenarios/mysql/20250830152220_create_posts.rb similarity index 100% rename from test/scenarios/20250830152220_create_posts.rb rename to test/scenarios/mysql/20250830152220_create_posts.rb diff --git a/test/scenarios/20250830170325_add_announcement_to_users.rb b/test/scenarios/mysql/20250830170325_add_announcement_to_users.rb similarity index 100% rename from test/scenarios/20250830170325_add_announcement_to_users.rb rename to test/scenarios/mysql/20250830170325_add_announcement_to_users.rb diff --git a/test/scenarios/20250830175957_add_announceable_to_users.rb b/test/scenarios/mysql/20250830175957_add_announceable_to_users.rb similarity index 100% rename from test/scenarios/20250830175957_add_announceable_to_users.rb rename to test/scenarios/mysql/20250830175957_add_announceable_to_users.rb diff --git a/test/scenarios/mysql/primary_db/database.yml b/test/scenarios/mysql/primary_db/database.yml new file mode 100644 index 00000000..faed9e0d --- /dev/null +++ b/test/scenarios/mysql/primary_db/database.yml @@ -0,0 +1,22 @@ +default: &default + adapter: mysql2 + encoding: utf8mb4 + pool: 20 + max_connections: 20 + 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: + primary: + <<: *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/primary_db/primary_record.rb b/test/scenarios/mysql/primary_db/primary_record.rb similarity index 100% rename from test/scenarios/primary_db/primary_record.rb rename to test/scenarios/mysql/primary_db/primary_record.rb diff --git a/test/scenarios/primary_db/secondary_record.rb b/test/scenarios/mysql/primary_db/secondary_record.rb similarity index 100% rename from test/scenarios/primary_db/secondary_record.rb rename to test/scenarios/mysql/primary_db/secondary_record.rb diff --git a/test/scenarios/primary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/mysql/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/mysql/primary_db/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/primary_db/subtenant_record.rb b/test/scenarios/mysql/primary_db/subtenant_record.rb similarity index 100% rename from test/scenarios/primary_db/subtenant_record.rb rename to test/scenarios/mysql/primary_db/subtenant_record.rb diff --git a/test/scenarios/primary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/mysql/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/mysql/primary_db/tenanted_migrations/20250203191115_create_users.rb 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..81abc69f --- /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: <%= 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: + 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/primary_named_db/primary_record.rb b/test/scenarios/mysql/primary_named_db/primary_record.rb similarity index 100% rename from test/scenarios/primary_named_db/primary_record.rb rename to test/scenarios/mysql/primary_named_db/primary_record.rb diff --git a/test/scenarios/primary_named_db/secondary_record.rb b/test/scenarios/mysql/primary_named_db/secondary_record.rb similarity index 100% rename from test/scenarios/primary_named_db/secondary_record.rb rename to test/scenarios/mysql/primary_named_db/secondary_record.rb diff --git a/test/scenarios/primary_named_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/mysql/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/mysql/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/mysql/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/mysql/primary_named_db/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/schema.rb b/test/scenarios/mysql/schema.rb similarity index 100% rename from test/scenarios/schema.rb rename to test/scenarios/mysql/schema.rb 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/scenarios/mysql/secondary_db/database.yml b/test/scenarios/mysql/secondary_db/database.yml new file mode 100644 index 00000000..87fa9800 --- /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: <%= 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: + 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/secondary_db/primary_record.rb b/test/scenarios/mysql/secondary_db/primary_record.rb similarity index 100% rename from test/scenarios/secondary_db/primary_record.rb rename to test/scenarios/mysql/secondary_db/primary_record.rb diff --git a/test/scenarios/secondary_db/secondary_record.rb b/test/scenarios/mysql/secondary_db/secondary_record.rb similarity index 100% rename from test/scenarios/secondary_db/secondary_record.rb rename to test/scenarios/mysql/secondary_db/secondary_record.rb diff --git a/test/scenarios/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/mysql/secondary_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/mysql/secondary_db/shared_migrations/20250203191207_create_announcements.rb diff --git a/test/scenarios/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/mysql/secondary_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/mysql/secondary_db/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/sqlite/20250213005959_add_age_to_users.rb b/test/scenarios/sqlite/20250213005959_add_age_to_users.rb new file mode 100644 index 00000000..bfe140c0 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/20250830152220_create_posts.rb b/test/scenarios/sqlite/20250830152220_create_posts.rb new file mode 100644 index 00000000..7242786a --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/20250830170325_add_announcement_to_users.rb b/test/scenarios/sqlite/20250830170325_add_announcement_to_users.rb new file mode 100644 index 00000000..73353f9a --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/20250830175957_add_announceable_to_users.rb b/test/scenarios/sqlite/20250830175957_add_announceable_to_users.rb new file mode 100644 index 00000000..70da8289 --- /dev/null +++ b/test/scenarios/sqlite/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/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_uri_db/primary_record.rb b/test/scenarios/sqlite/primary_db/primary_record.rb similarity index 100% rename from test/scenarios/primary_uri_db/primary_record.rb rename to test/scenarios/sqlite/primary_db/primary_record.rb diff --git a/test/scenarios/primary_uri_db/secondary_record.rb b/test/scenarios/sqlite/primary_db/secondary_record.rb similarity index 100% rename from test/scenarios/primary_uri_db/secondary_record.rb rename to test/scenarios/sqlite/primary_db/secondary_record.rb diff --git a/test/scenarios/secondary_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/secondary_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/sqlite/primary_db/subtenant_record.rb b/test/scenarios/sqlite/primary_db/subtenant_record.rb new file mode 100644 index 00000000..5f3a22ba --- /dev/null +++ b/test/scenarios/sqlite/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/secondary_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/secondary_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/sqlite/primary_named_db/primary_record.rb b/test/scenarios/sqlite/primary_named_db/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/primary_named_db/secondary_record.rb b/test/scenarios/sqlite/primary_named_db/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/primary_named_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/primary_named_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/primary_named_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/primary_named_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/sqlite/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/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/sqlite/primary_uri_db/primary_record.rb b/test/scenarios/sqlite/primary_uri_db/primary_record.rb new file mode 100644 index 00000000..f9ad6c58 --- /dev/null +++ b/test/scenarios/sqlite/primary_uri_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/sqlite/primary_uri_db/secondary_record.rb b/test/scenarios/sqlite/primary_uri_db/secondary_record.rb new file mode 100644 index 00000000..7fdd930a --- /dev/null +++ b/test/scenarios/sqlite/primary_uri_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/sqlite/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/primary_uri_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/sqlite/primary_uri_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/sqlite/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/primary_uri_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/sqlite/primary_uri_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/sqlite/schema.rb b/test/scenarios/sqlite/schema.rb new file mode 100644 index 00000000..74d3e220 --- /dev/null +++ b/test/scenarios/sqlite/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/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/sqlite/secondary_db/primary_record.rb b/test/scenarios/sqlite/secondary_db/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/secondary_db/secondary_record.rb b/test/scenarios/sqlite/secondary_db/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/secondary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/secondary_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/secondary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/secondary_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/sqlite/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/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 92a0bed6..93e5026f 100644 --- a/test/smarty/test/application_system_test_case.rb +++ b/test/smarty/test/application_system_test_case.rb @@ -3,5 +3,13 @@ Capybara.server = :puma, { Silent: true } # suppress server boot announcement class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + 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 + + def setup + ActionCable.server.config.cable = { "adapter" => "async" } + super + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index e8cf74c9..c4969301 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,51 @@ def for_each_scenario(s = all_scenarios, except: {}, &block) end def all_scenarios - Dir.glob(File.join(__dir__, "scenarios", "*", "database.yml")) + 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, ".*") } + 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 +147,8 @@ def with_db_scenario(db_scenario, &block) end teardown do + 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 @@ -199,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 @@ -207,13 +237,17 @@ def base_config end def with_schema_dump_file - FileUtils.cp "test/scenarios/schema.rb", - 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 - FileUtils.cp "test/scenarios/schema_cache.yml", - 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 @@ -221,7 +255,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 +281,30 @@ def capture_rails_log end private + 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? + + 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 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..2aa48849 100644 --- a/test/unit/database_configurations_test.rb +++ b/test/unit/database_configurations_test.rb @@ -182,6 +182,166 @@ 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 "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" } @@ -219,10 +379,25 @@ 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 - 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 +427,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/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 } diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index d77fd243..07cff6ef 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,10 +631,26 @@ 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 - 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 @@ -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"))