diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b1907b2..42cc912f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,31 @@ 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 + 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 @@ -35,9 +60,41 @@ jobs: ruby-version: "3.4" bundler-cache: true - run: bin/test-unit + 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 + 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 + 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 @@ -45,3 +102,10 @@ jobs: ruby-version: "3.4" bundler-cache: true - run: bin/test-integration + 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/GUIDE.md b/GUIDE.md index 76976156..dbf4ebaa 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -272,6 +272,138 @@ class ApplicationRecord < ActiveRecord::Base end ``` +#### 2.2.1 PostgreSQL Multi-Tenancy Strategies + +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 recommended strategy for most use cases. + +**Configuration:** + +``` yaml +production: + primary: + adapter: postgresql + database: myapp_production # Static database name + tenanted: true + host: localhost +``` + +In this configuration: +- A single PostgreSQL database named `myapp_production` is created (static name) +- 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:** Automatically used when database name does NOT contain `%{tenant}` + +**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 + database: "%{tenant}" + tenanted: true + host: localhost +``` + +In this configuration: +- 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 +- **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 (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) +- 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 +- 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`: + - 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 + +**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 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 + ### 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. @@ -290,6 +422,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/Gemfile b/Gemfile index 90435e88..7f5b9880 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,8 @@ 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 + gem "pg", "~> 1.1", require: false end group :rubocop do diff --git a/Gemfile.lock b/Gemfile.lock index 23dcda6d..1bd26205 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 @@ -199,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) @@ -349,6 +355,8 @@ DEPENDENCIES importmap-rails 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..5758e057 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. 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/bin/setup b/bin/setup new file mode 100755 index 00000000..9549dfaf --- /dev/null +++ b/bin/setup @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -e + +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." + 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 "^${MYSQL_CONTAINER_NAME}$"; then + echo " Stopping and removing existing container..." + 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="$MYSQL_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 "$MYSQL_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 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 + +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 f3d896a0..6f5a1dc7 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -17,12 +17,21 @@ 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/**/*record.rb").map do |path| + adapter, database, models = path.scan(%r{test/scenarios/(.+)/([^/]+)/(.+record).rb}).flatten + { 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" @@ -49,13 +58,25 @@ 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 + # 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[: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 +84,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,22 +103,26 @@ 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 }) + elsif scenario[:adapter] == "postgresql" + adapter_config.merge!({ "POSTGRES_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? + # 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/lib/active_record/tenanted.rb b/lib/active_record/tenanted.rb index 1b0b62b1..c8b526a9 100644 --- a/lib/active_record/tenanted.rb +++ b/lib/active_record/tenanted.rb @@ -6,6 +6,8 @@ loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord) loader.inflector.inflect( "sqlite" => "SQLite", + "mysql" => "MySQL", + "postgresql" => "PostgreSQL", ) loader.setup @@ -41,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/cable_connection.rb b/lib/active_record/tenanted/cable_connection.rb index 7679b566..4161013b 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..c7d878d0 100644 --- a/lib/active_record/tenanted/database_adapter.rb +++ b/lib/active_record/tenanted/database_adapter.rb @@ -25,6 +25,9 @@ def new(db_config) end register "sqlite3", "ActiveRecord::Tenanted::DatabaseAdapters::SQLite" + register "trilogy", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" + register "mysql2", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" + register "postgresql", "ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory" end end end 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/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_adapters/postgresql.rb b/lib/active_record/tenanted/database_adapters/postgresql.rb new file mode 100644 index 00000000..c6a368d7 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql.rb @@ -0,0 +1,20 @@ +# 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 + # PostgreSQL adapter support for multi-tenancy + # + # Supports two strategies: + # - Schema-based (preferred): Multiple schemas within a single database + # - Database-based (default): Separate databases per tenant + module PostgreSQL + end + 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..feaeba63 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/base.rb @@ -0,0 +1,111 @@ +# 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 + + def maintenance_db_name + db_config.configuration_hash[:maintenance_database] || "postgres" + 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..7b868e6d --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/database.rb @@ -0,0 +1,171 @@ +# 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 + # 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) + + # 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 + + 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 + + # 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 + 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 `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 `schema_name_pattern` " \ + "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: maintenance_db_name, # Connect to 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..5d23e1c4 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/factory.rb @@ -0,0 +1,29 @@ +# 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 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 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 + 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..53d3226c --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/postgresql/schema.rb @@ -0,0 +1,269 @@ +# 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: + # + # Colocated approach (recommended): + # adapter: postgresql + # tenanted: true + # database: myapp_production + # + # The adapter will: + # - Connect to a single base database + # - Create/use schemas for tenant isolation + # - Set schema_search_path to isolate tenants + class Schema < Base + include Colocated + + def initialize(db_config) + super + 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 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 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 + # 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 + + 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 = identifier_for(tenant_name) + database_name = base_config.database + + config_hash.merge( + schema_search_path: schema_name, + tenant_schema: schema_name, + database: database_name + ) + end + + def identifier_for(tenant_name) + sprintf("%{tenant}", tenant: tenant_name.to_s) + 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: maintenance_db_name, + 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) + + # 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 + # 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 + db_config.database + end + + def schema_name_for(tenant_name) + # Generate schema name from tenant name + # Delegate to identifier_for for consistency + identifier_for(tenant_name) + 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 51e37bbe..8e93abd2 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -37,15 +37,37 @@ 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) config_name = "#{name}_#{tenant_name}" config_hash = configuration_hash.dup.tap do |hash| hash[:tenant] = 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 + 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 @@ -60,6 +82,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_configurations/tenant_config.rb b/lib/active_record/tenanted/database_configurations/tenant_config.rb index 93a4f835..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 @@ -22,7 +28,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 + + # 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 + end end def tenanted_config_name diff --git a/lib/active_record/tenanted/database_tasks.rb b/lib/active_record/tenanted/database_tasks.rb index 55201b14..7c74240d 100644 --- a/lib/active_record/tenanted/database_tasks.rb +++ b/lib/active_record/tenanted/database_tasks.rb @@ -33,9 +33,22 @@ 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 - tenants.each do |tenant| - drop_tenant(tenant) + # 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| + drop_tenant(tenant) + end end end @@ -46,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 @@ -81,20 +100,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 +132,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 @@ -122,6 +151,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 @@ -160,6 +195,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/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/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/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/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/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 diff --git a/test/integration/test/turbo_broadcast_test.rb b/test/integration/test/turbo_broadcast_test.rb index cf7f31af..ed2f1cf8 100644 --- a/test/integration/test/turbo_broadcast_test.rb +++ b/test/integration/test/turbo_broadcast_test.rb @@ -6,13 +6,14 @@ 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") + wait_for_turbo_stream_connected + note1.update!(body: "note 1 version 2") assert_text("note 1 version 2") @@ -22,4 +23,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..1dde3001 --- /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') %> + ssl_mode: 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..c3526e6b --- /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') %> + ssl_mode: 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..4468aebb --- /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') %> + ssl_mode: 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/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_strategy/database.yml b/test/scenarios/postgresql/primary_db_database_strategy/database.yml new file mode 100644 index 00000000..b2d4b62e --- /dev/null +++ b/test/scenarios/postgresql/primary_db_database_strategy/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/primary_uri_db/primary_record.rb b/test/scenarios/postgresql/primary_db_database_strategy/primary_record.rb similarity index 100% rename from test/scenarios/primary_uri_db/primary_record.rb rename to test/scenarios/postgresql/primary_db_database_strategy/primary_record.rb diff --git a/test/scenarios/primary_uri_db/secondary_record.rb b/test/scenarios/postgresql/primary_db_database_strategy/secondary_record.rb similarity index 100% rename from test/scenarios/primary_uri_db/secondary_record.rb rename to test/scenarios/postgresql/primary_db_database_strategy/secondary_record.rb diff --git a/test/scenarios/secondary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_db_database_strategy/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/postgresql/primary_db_database_strategy/shared_migrations/20250203191207_create_announcements.rb 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/secondary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_db_database_strategy/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/postgresql/primary_db_database_strategy/tenanted_migrations/20250203191115_create_users.rb diff --git a/test/scenarios/postgresql/primary_db_schema_strategy/database.yml b/test/scenarios/postgresql/primary_db_schema_strategy/database.yml new file mode 100644 index 00000000..03f2469f --- /dev/null +++ b/test/scenarios/postgresql/primary_db_schema_strategy/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') %> + 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_schema_strategy/primary_record.rb b/test/scenarios/postgresql/primary_db_schema_strategy/primary_record.rb new file mode 100644 index 00000000..f9ad6c58 --- /dev/null +++ b/test/scenarios/postgresql/primary_db_schema_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_schema_strategy/secondary_record.rb b/test/scenarios/postgresql/primary_db_schema_strategy/secondary_record.rb new file mode 100644 index 00000000..7fdd930a --- /dev/null +++ b/test/scenarios/postgresql/primary_db_schema_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_schema_strategy/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_db_schema_strategy/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/primary_db_schema_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_schema_strategy/subtenant_record.rb b/test/scenarios/postgresql/primary_db_schema_strategy/subtenant_record.rb new file mode 100644 index 00000000..5f3a22ba --- /dev/null +++ b/test/scenarios/postgresql/primary_db_schema_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_schema_strategy/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_db_schema_strategy/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/primary_db_schema_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_strategy/database.yml b/test/scenarios/postgresql/primary_named_db_database_strategy/database.yml new file mode 100644 index 00000000..e5bbd2a4 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_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: + 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_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/primary_named_db_schema_strategy/database.yml b/test/scenarios/postgresql/primary_named_db_schema_strategy/database.yml new file mode 100644 index 00000000..e5bbd2a4 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_schema_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: + 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_schema_strategy/primary_record.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_schema_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_schema_strategy/secondary_record.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_schema_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_schema_strategy/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_schema_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_schema_strategy/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/primary_named_db_schema_strategy/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/primary_named_db_schema_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/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_strategy/database.yml b/test/scenarios/postgresql/secondary_db_database_strategy/database.yml new file mode 100644 index 00000000..b50d0f51 --- /dev/null +++ b/test/scenarios/postgresql/secondary_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: + 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_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/scenarios/postgresql/secondary_db_schema_stragegy/database.yml b/test/scenarios/postgresql/secondary_db_schema_stragegy/database.yml new file mode 100644 index 00000000..b50d0f51 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_schema_stragegy/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_schema_stragegy/primary_record.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/primary_record.rb new file mode 100644 index 00000000..442eadb0 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_schema_stragegy/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_schema_stragegy/secondary_record.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/secondary_record.rb new file mode 100644 index 00000000..0a1c8863 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_schema_stragegy/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_schema_stragegy/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_schema_stragegy/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_schema_stragegy/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/postgresql/secondary_db_schema_stragegy/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/postgresql/secondary_db_schema_stragegy/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/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/sqlite/primary_db/primary_record.rb b/test/scenarios/sqlite/primary_db/primary_record.rb new file mode 100644 index 00000000..f9ad6c58 --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/primary_db/secondary_record.rb b/test/scenarios/sqlite/primary_db/secondary_record.rb new file mode 100644 index 00000000..7fdd930a --- /dev/null +++ b/test/scenarios/sqlite/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/sqlite/primary_db/shared_migrations/20250203191207_create_announcements.rb b/test/scenarios/sqlite/primary_db/shared_migrations/20250203191207_create_announcements.rb new file mode 100644 index 00000000..da8acde4 --- /dev/null +++ b/test/scenarios/sqlite/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/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/sqlite/primary_db/tenanted_migrations/20250203191115_create_users.rb b/test/scenarios/sqlite/primary_db/tenanted_migrations/20250203191115_create_users.rb new file mode 100644 index 00000000..a04dbd6e --- /dev/null +++ b/test/scenarios/sqlite/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/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..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 @@ -24,6 +27,7 @@ class TestSuiteRailtie < ::Rails::Railtie require_relative "../lib/active_record/tenanted" require_relative "dummy/config/environment" +require "erb" require "minitest/spec" require "minitest/mock" @@ -43,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.) @@ -62,11 +135,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 +152,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, tenant: "%{tenant}") + end + let(:db_config) { YAML.load(db_config_yml, aliases: true) } setup do FileUtils.mkdir(db_path) @@ -119,6 +219,9 @@ def with_db_scenario(db_scenario, &block) end 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 @@ -199,7 +302,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 +310,19 @@ def base_config end def with_schema_dump_file - FileUtils.cp "test/scenarios/schema.rb", - ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(base_config) + # 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 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 +330,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 +356,47 @@ 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? + + 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] } + return unless base_config + + 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| + # 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 + # Log warning but don't fail the teardown + warn "Failed to cleanup tenant database #{tenant_name}: #{e.class}: #{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)) @@ -273,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_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_base_test.rb b/test/unit/database_adapters_postgresql_base_test.rb new file mode 100644 index 00000000..03118492 --- /dev/null +++ b/test/unit/database_adapters_postgresql_base_test.rb @@ -0,0 +1,158 @@ +# 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 + + 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 + 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..249a7039 --- /dev/null +++ b/test/unit/database_adapters_postgresql_database_test.rb @@ -0,0 +1,49 @@ +# 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}" } + 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 tenant_schema is configured" do + config_hash = { + adapter: "postgresql", + database: "myapp_%{tenant}", + 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) + 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..180d0d7f --- /dev/null +++ b/test/unit/database_adapters_postgresql_factory_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory do + describe "strategy selection" 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) + + adapter = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Factory.new(db_config) + + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Database, adapter + end + + 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) + + assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema, adapter + end + + test "returns Database adapter with just %{tenant} as database name" do + config_hash = { + adapter: "postgresql", + 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::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 new file mode 100644 index 00000000..b3d7f854 --- /dev/null +++ b/test/unit/database_adapters_postgresql_schema_test.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema do + let(:db_config) do + 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 "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 "raises error if tenant_schema not present" do + db_config_dynamic = Object.new + 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) + + error = assert_raises(ActiveRecord::Tenanted::NoTenantError) do + adapter_dynamic.database_path + end + + assert_match(/tenant_schema not set/, error.message) + end + end + + describe "prepare_tenant_config_hash" do + test "adds schema-specific configuration with account- prefix" do + base_config = Object.new + def base_config.database; "myapp_development"; end + def base_config.configuration_hash; {}; end + + config_hash = { tenant: "foo", database: "myapp_development" } + result = adapter.prepare_tenant_config_hash(config_hash, base_config, "foo") + + assert_equal "account-foo", result[:schema_search_path] + assert_equal "account-foo", result[:tenant_schema] + assert_equal "myapp_development", result[:database] + end + + test "uses static database name" do + base_config = Object.new + def base_config.database; "rails_backend_production"; end + def base_config.configuration_hash; {}; end + + config_hash = { tenant: "bar" } + result = adapter.prepare_tenant_config_hash(config_hash, base_config, "bar") + + # 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 "rails_backend_production", result[:database] + end + + test "creates schema name with account- prefix for complex tenant" do + base_config = Object.new + def base_config.database; "myapp_production"; end + def base_config.configuration_hash; {}; end + + config_hash = { tenant: "abc123" } + result = adapter.prepare_tenant_config_hash(config_hash, base_config, "abc123") + + # 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 "myapp_production", result[:database] + end + end + + describe "identifier_for" do + 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 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_static = ActiveRecord::Tenanted::DatabaseAdapters::PostgreSQL::Schema.new(db_config_static) + result = adapter_static.identifier_for("abc123") + assert_equal "account-abc123", result + 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 static database config" do + # This test verifies that create_colocated_database fully integrates with Rails + 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_static) + base_db_name = "myapp_development" + + # 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 + + 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_static.env_name; "test"; end + def db_config_static.name; "primary"; end + + 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 + 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 static database config" do + # This test verifies that drop_colocated_database fully integrates with Rails + 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_static) + base_db_name = "myapp_development" + + # 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 + + 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_static.env_name; "test"; end + def db_config_static.name; "primary"; end + + 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 + 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 new file mode 100644 index 00000000..933a0c05 --- /dev/null +++ b/test/unit/database_adapters_postgresql_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "test_helper" + +# 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 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::Schema, adapter + end + + 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::Database, adapter + end + end +end 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..d51e0195 100644 --- a/test/unit/database_tasks_test.rb +++ b/test/unit/database_tasks_test.rb @@ -3,6 +3,83 @@ 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 + + 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?(:colocated?) + + # 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 for_each_scenario do setup do @@ -32,6 +109,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 +140,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 +187,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/postgresql_colocated_schema_test.rb b/test/unit/postgresql_colocated_schema_test.rb new file mode 100644 index 00000000..7441a4c0 --- /dev/null +++ b/test/unit/postgresql_colocated_schema_test.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "test_helper" + +describe "PostgreSQL Colocated Schema Strategy" 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 + + # Database name should not contain %{tenant} + assert_not config.database.include?("%{tenant}") + + # 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 are prefixed with account-" 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 "account-#{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_not db_name_1.include?("%{tenant}") + 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 diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index d77fd243..ac79ecab 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") @@ -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 @@ -827,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 @@ -901,9 +948,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"))