diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml deleted file mode 100644 index b0676090..00000000 --- a/.github/workflows/close-stale-issues.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Close Stale Issues - -on: - schedule: - - cron: '0 0 * * *' # Runs daily at midnight - workflow_dispatch: - -permissions: - contents: write # only for delete-branch option - issues: write - pull-requests: write - -jobs: - close-stale-issues: - name: Close Stale Issues - runs-on: ubuntu-latest - steps: - - name: Close stale issues and pull requests - uses: actions/stale@v9 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' - stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' - days-before-stale: 30 - days-before-close: 7 - stale-issue-label: 'stale' - exempt-issue-labels: 'pinned,security' - stale-pr-label: 'stale' - exempt-pr-labels: 'work-in-progress' - delete-branch: true \ No newline at end of file diff --git a/.github/workflows/gem-publish.yml b/.github/workflows/gem-publish.yml index 4060eaaa..0c923b0f 100644 --- a/.github/workflows/gem-publish.yml +++ b/.github/workflows/gem-publish.yml @@ -1,31 +1,24 @@ name: Publish to RubyGems + on: push: branches: [ 'main' ] - paths: - - 'lib/apartment/version.rb' - pull_request: - branches: [ 'main' ] - types: [ 'closed' ] - paths: - - 'lib/apartment/version.rb' jobs: - build: - if: github.event.pull_request.merged == true + release: name: Build + Publish runs-on: ubuntu-latest environment: production permissions: - id-token: write - contents: write + id-token: write # Required for trusted publishing to RubyGems.org + contents: write # Required for rake release to push the release tag steps: - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 with: bundler-cache: true - rubygems: latest - bundler: latest + ruby-version: .ruby-version - name: Publish to RubyGems uses: rubygems/release-gem@v1 diff --git a/.github/workflows/rspec_mysql_8_0.yml b/.github/workflows/rspec_mysql_8_0.yml index bc8b1b79..3c8d0b7a 100644 --- a/.github/workflows/rspec_mysql_8_0.yml +++ b/.github/workflows/rspec_mysql_8_0.yml @@ -23,11 +23,11 @@ jobs: - 3.4 - jruby rails_version: - - 6_1 - 7_0 - 7_1 - 7_2 - 8_0 + - 8_1 exclude: - ruby_version: jruby rails_version: 7_1 @@ -37,6 +37,10 @@ jobs: rails_version: 8_0 - ruby_version: 3.1 rails_version: 8_0 + - ruby_version: jruby + rails_version: 8_1 + - ruby_version: 3.1 + rails_version: 8_1 env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_mysql.gemfile CI: true diff --git a/.github/workflows/rspec_pg_14.yml b/.github/workflows/rspec_pg_14.yml index 3a1e42cd..008003d1 100644 --- a/.github/workflows/rspec_pg_14.yml +++ b/.github/workflows/rspec_pg_14.yml @@ -23,11 +23,11 @@ jobs: - 3.4 - jruby rails_version: - - 6_1 - 7_0 - 7_1 - 7_2 - 8_0 + - 8_1 exclude: - ruby_version: jruby rails_version: 7_1 @@ -37,6 +37,11 @@ jobs: rails_version: 8_0 - ruby_version: 3.1 rails_version: 8_0 + - ruby_version: jruby + rails_version: 8_1 + - ruby_version: 3.1 + rails_version: 8_1 + - ruby_version: 3.1 env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile CI: true @@ -102,4 +107,4 @@ jobs: file: ./coverage/test-results.xml - name: Notify of test failure if: steps.rspec-tests.outcome == 'failure' - run: exit 1 \ No newline at end of file + run: exit 1 diff --git a/.github/workflows/rspec_pg_15.yml b/.github/workflows/rspec_pg_15.yml index 15423ebf..6e862b19 100644 --- a/.github/workflows/rspec_pg_15.yml +++ b/.github/workflows/rspec_pg_15.yml @@ -23,11 +23,11 @@ jobs: - 3.4 - jruby rails_version: - - 6_1 - 7_0 - 7_1 - 7_2 - 8_0 + - 8_1 exclude: - ruby_version: jruby rails_version: 7_1 @@ -37,6 +37,11 @@ jobs: rails_version: 8_0 - ruby_version: 3.1 rails_version: 8_0 + - ruby_version: jruby + rails_version: 8_1 + - ruby_version: 3.1 + rails_version: 8_1 + - ruby_version: 3.1 env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile CI: true @@ -102,4 +107,4 @@ jobs: file: ./coverage/test-results.xml - name: Notify of test failure if: steps.rspec-tests.outcome == 'failure' - run: exit 1 \ No newline at end of file + run: exit 1 diff --git a/.github/workflows/rspec_pg_16.yml b/.github/workflows/rspec_pg_16.yml index 06f498c6..ac5ada3d 100644 --- a/.github/workflows/rspec_pg_16.yml +++ b/.github/workflows/rspec_pg_16.yml @@ -23,10 +23,11 @@ jobs: - 3.4 - jruby rails_version: - - 6_1 - 7_0 - 7_1 - 7_2 + - 8_0 + - 8_1 exclude: - ruby_version: jruby rails_version: 7_1 @@ -36,6 +37,11 @@ jobs: rails_version: 8_0 - ruby_version: 3.1 rails_version: 8_0 + - ruby_version: jruby + rails_version: 8_1 + - ruby_version: 3.1 + rails_version: 8_1 + - ruby_version: 3.1 env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile CI: true @@ -101,4 +107,4 @@ jobs: file: ./coverage/test-results.xml - name: Notify of test failure if: steps.rspec-tests.outcome == 'failure' - run: exit 1 \ No newline at end of file + run: exit 1 diff --git a/.github/workflows/rspec_pg_17.yml b/.github/workflows/rspec_pg_17.yml index 83bb406c..fdb3b293 100644 --- a/.github/workflows/rspec_pg_17.yml +++ b/.github/workflows/rspec_pg_17.yml @@ -23,11 +23,11 @@ jobs: - 3.4 - jruby rails_version: - - 6_1 - 7_0 - 7_1 - 7_2 - 8_0 + - 8_1 exclude: - ruby_version: jruby rails_version: 7_1 @@ -37,6 +37,11 @@ jobs: rails_version: 8_0 - ruby_version: 3.1 rails_version: 8_0 + - ruby_version: jruby + rails_version: 8_1 + - ruby_version: 3.1 + rails_version: 8_1 + - ruby_version: 3.1 env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile CI: true @@ -102,4 +107,4 @@ jobs: file: ./coverage/test-results.xml - name: Notify of test failure if: steps.rspec-tests.outcome == 'failure' - run: exit 1 \ No newline at end of file + run: exit 1 diff --git a/.github/workflows/rspec_pg_18.yml b/.github/workflows/rspec_pg_18.yml new file mode 100644 index 00000000..9c884756 --- /dev/null +++ b/.github/workflows/rspec_pg_18.yml @@ -0,0 +1,110 @@ +name: RSpec PostgreSQL 18 +on: + push: + branches: + - development + - main + pull_request: + types: [opened, synchronize, reopened] + release: + types: [published] + +jobs: + test: + name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby_version: + - 3.1 + - 3.2 + - 3.3 + - 3.4 + - jruby + rails_version: + - 7_0 + - 7_1 + - 7_2 + - 8_0 + - 8_1 + exclude: + - ruby_version: jruby + rails_version: 7_1 + - ruby_version: jruby + rails_version: 7_2 + - ruby_version: jruby + rails_version: 8_0 + - ruby_version: 3.1 + rails_version: 8_0 + - ruby_version: jruby + rails_version: 8_1 + - ruby_version: 3.1 + rails_version: 8_1 + - ruby_version: 3.1 + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile + CI: true + DATABASE_ENGINE: postgresql + RUBY_VERSION: ${{ matrix.ruby_version }} + RAILS_VERSION: ${{ matrix.rails_version }} + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: apartment_postgresql_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - name: Install PostgreSQL client + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends postgresql-common + echo | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends postgresql-client-18 + - uses: actions/checkout@v4 + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + - name: Configure config database.yml + run: bundle exec rake db:load_credentials + - name: Database Setup + run: bundle exec rake db:test:prepare + - name: Run tests + id: rspec-tests + timeout-minutes: 20 + continue-on-error: true + run: | + mkdir -p ./coverage + bundle exec rspec --format progress \ + --format RspecJunitFormatter -o ./coverage/test-results.xml \ + --profile + - name: Codecov Upload + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + disable_search: true + env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION + file: ./coverage/coverage.json + - name: Upload test results to Codecov + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + disable_search: true + env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION + file: ./coverage/test-results.xml + - name: Notify of test failure + if: steps.rspec-tests.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/rspec_sqlite_3.yml b/.github/workflows/rspec_sqlite_3.yml index 518d3916..11eeb089 100644 --- a/.github/workflows/rspec_sqlite_3.yml +++ b/.github/workflows/rspec_sqlite_3.yml @@ -23,20 +23,16 @@ jobs: - 3.4 # - jruby # We don't support jruby for sqlite yet rails_version: - - 6_1 - 7_0 - 7_1 - 7_2 - 8_0 + - 8_1 exclude: - ruby_version: 3.1 rails_version: 8_0 - # - ruby_version: jruby - # rails_version: 7_1 - # - ruby_version: jruby - # rails_version: 7_2 - # - ruby_version: jruby - # rails_version: 8_0 + - ruby_version: 3.1 + rails_version: 8_1 env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_sqlite3.gemfile CI: true @@ -81,4 +77,4 @@ jobs: file: ./coverage/test-results.xml - name: Notify of test failure if: steps.rspec-tests.outcome == 'failure' - run: exit 1 \ No newline at end of file + run: exit 1 diff --git a/.gitignore b/.gitignore index 01c3930e..4844af35 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ cookbooks tmp spec/dummy/db/*.sqlite3 .DS_Store +.claude/ diff --git a/.rubocop.yml b/.rubocop.yml index 67b73f9a..1906fd9b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,7 +7,7 @@ AllCops: - spec/dummy_engine/dummy_engine.gemspec - spec/schemas/**/*.rb -require: +plugins: - rubocop-rails - rubocop-performance - rubocop-thread_safety @@ -22,6 +22,26 @@ Layout/MultilineMethodCallIndentation: EnforcedStyle: indented Metrics/BlockLength: + Max: 30 + Exclude: + - spec/**/*.rb + - lib/tasks/**/*.rake + - Rakefile + +Metrics/MethodLength: + Max: 15 + Exclude: + - spec/**/*.rb + - lib/apartment/tenant.rb + +Metrics/AbcSize: + Max: 20 + Exclude: + - spec/**/*.rb + - lib/apartment/adapters/postgresql_adapter.rb + +Metrics/ClassLength: + Max: 155 Exclude: - spec/**/*.rb @@ -76,4 +96,75 @@ Style/CollectionMethods: collect!: 'map!' inject: 'reduce' detect: 'detect' - find_all: 'select' \ No newline at end of file + find_all: 'select' + +# RSpec style preferences - disable for mature test suite +RSpec/NamedSubject: + Enabled: false + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/MessageSpies: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +RSpec/ContextWording: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/InstanceVariable: + Enabled: false + +RSpec/SpecFilePathFormat: + Enabled: false + +RSpec/DescribeClass: + Enabled: false + +RSpec/IndexedLet: + Enabled: false + +RSpec/AnyInstance: + Enabled: false + +RSpec/BeforeAfterAll: + Enabled: false + +RSpec/LeakyConstantDeclaration: + Enabled: false + +RSpec/VerifiedDoubles: + Enabled: false + +RSpec/NoExpectationExample: + Enabled: false + +# ThreadSafety - intentional design for configuration +ThreadSafety/ClassInstanceVariable: + Exclude: + - lib/apartment.rb + - lib/apartment/model.rb + - lib/apartment/elevators/*.rb + - spec/support/config.rb + +ThreadSafety/ClassAndModuleAttributes: + Exclude: + - lib/apartment.rb + - lib/apartment/active_record/postgresql_adapter.rb + +ThreadSafety/DirChdir: + Exclude: + - ros-apartment.gemspec + +ThreadSafety/NewThread: + Exclude: + - spec/tenant_spec.rb + +# Rake cops +Rake/DuplicateTask: + Enabled: false \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index 9c25013d..5f6fc5ed 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.6 +3.3.10 diff --git a/Appraisals b/Appraisals index 988b32c8..004cabde 100644 --- a/Appraisals +++ b/Appraisals @@ -128,3 +128,18 @@ appraise 'rails-8-0-sqlite3' do gem 'rails', '~> 8.0.0' gem 'sqlite3', '~> 2.1' end + +appraise 'rails-8-1-postgresql' do + gem 'rails', '~> 8.1.0' + gem 'pg', '~> 1.6.0' +end + +appraise 'rails-8-1-mysql' do + gem 'rails', '~> 8.1.0' + gem 'mysql2', '~> 0.5' +end + +appraise 'rails-8-1-sqlite3' do + gem 'rails', '~> 8.1.0' + gem 'sqlite3', '~> 2.8' +end diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..77c53d36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,210 @@ +# CLAUDE.md - Apartment v3 Understanding Guide + +**Version**: 3.x (Current Development Branch) +**Maintained by**: CampusESP +**Gem Name**: `ros-apartment` + +## What This Documentation Covers + +This branch contains v3 (current stable release). A v4 refactor with different architecture exists on `man/spec-restart` branch. + +**Goal**: Understand v3 deeply enough to maintain it and plan v4 migration. + +## Where to Start + +1. **README.md** - Installation, basic usage, configuration options +2. **docs/architecture.md** - Core design decisions and WHY they were made +3. **docs/adapters.md** - Database strategy trade-offs +4. **docs/elevators.md** - Middleware design rationale +5. **lib/apartment/CLAUDE.md** - Implementation file guide +6. **spec/CLAUDE.md** - Test organization and patterns + +## Core Concepts + +### Multi-Tenancy via Database Isolation + +**Problem**: Single application needs to serve multiple customers with data completely separated. + +**v3 Solution**: Thread-local tenant switching. Each request/thread tracks which tenant it's serving. + +**Key limitation**: Not fiber-safe (fibers share thread-local storage). + +### Two Main Strategies + +**PostgreSQL (schemas)**: Multiple namespaces in single database. Fast, scales to 100+ tenants. + +**MySQL (databases)**: Separate database per tenant. Complete isolation, slower switching. + +**See**: `docs/adapters.md` for trade-offs. + +### Automatic Tenant Detection + +**Middleware ("Elevators")**: Rack middleware extracts tenant from request (subdomain, domain, header). + +**Critical**: Must position before session middleware to avoid data leakage. + +**See**: `docs/elevators.md` for design decisions. + +## Key Architecture Decisions + +### 1. Thread-Local Adapter Storage + +**Why**: Concurrent requests need isolated tenant contexts without global locks. + +**Implementation**: `Thread.current[:apartment_adapter]` + +**Trade-off**: Not fiber-safe, but works for 99% of Rails deployments. + +**See**: `Apartment::Tenant.adapter` method in `tenant.rb`, `docs/architecture.md` + +### 2. Block-Based Tenant Switching + +**Why**: Automatic cleanup even on exceptions prevents tenant context leakage. + +**Pattern**: `Apartment::Tenant.switch(tenant) { ... }` with ensure block + +**Alternative rejected**: Manual switch/reset - too error-prone. + +**See**: `AbstractAdapter#switch` method in `adapters/abstract_adapter.rb` + +### 3. Excluded Models + +**Why**: Some models (User, Company) exist globally across all tenants. + +**Implementation**: Separate connections that bypass tenant switching. + +**Limitation**: Can't use `has_and_belongs_to_many` - must use `has_many :through`. + +**See**: `AbstractAdapter#process_excluded_models` method in `adapters/abstract_adapter.rb` + +### 4. Adapter Pattern + +**Why**: PostgreSQL uses schemas, MySQL uses databases - fundamentally different. + +**Implementation**: Abstract base class with database-specific subclasses. + +**Benefit**: Unified API hides database differences. + +**See**: `lib/apartment/adapters/`, `docs/adapters.md` + +### 5. Callback System + +**Why**: Users need logging/notification hooks without modifying gem code. + +**Implementation**: ActiveSupport::Callbacks on `:create` and `:switch`. + +**See**: Callback definitions in `AbstractAdapter` class in `adapters/abstract_adapter.rb` + +## File Organization + +**Core logic**: `lib/apartment.rb` (configuration), `lib/apartment/tenant.rb` (public API) + +**Adapters**: `lib/apartment/adapters/*.rb` - Database-specific implementations + +**Elevators**: `lib/apartment/elevators/*.rb` - Rack middleware for auto-switching + +**Tests**: `spec/` - Adapter tests, elevator tests, integration tests + +**See folder CLAUDE.md files for details on each directory.** + +## Configuration Philosophy + +**Dynamic tenant discovery**: `tenant_names` can be callable (proc/lambda) that queries database. Why? Tenants change at runtime. + +**Fail-safe boot**: Rescue database errors during config loading. Why? App should start even if tenant table doesn't exist yet (pending migrations). + +**Environment isolation**: Optional `prepend_environment`/`append_environment` to prevent cross-environment tenant name collisions. + +**See**: `Apartment.extract_tenant_config` method in `lib/apartment.rb` + +## Common Pitfalls + +**Elevator positioning**: Must be before session/auth middleware. Otherwise session data leaks across tenants. + +**Not using blocks**: `switch!` without block requires manual cleanup. Easy to forget. Always prefer `switch` with block. + +**HABTM with excluded models**: Doesn't work. Must use `has_many :through` instead. + +**Assuming fiber safety**: v3 uses thread-local storage. Not safe for fiber-based async frameworks. + +**See**: `docs/architecture.md` for detailed analysis + +## Performance Characteristics + +**PostgreSQL schemas**: +- Switch: <1ms +- Scalability: 100+ tenants +- Memory: Constant + +**MySQL databases**: +- Switch: 10-50ms +- Scalability: 10-50 tenants +- Memory: Linear with active tenants + +**See**: `docs/adapters.md` for benchmarks and trade-offs + +## Testing the Gem + +**Spec organization**: `spec/adapters/` for database tests, `spec/unit/elevators/` for middleware tests + +**Database selection**: `DB=postgresql rspec` or `DB=mysql` or `DB=sqlite3` + +**Key test pattern**: Create test tenant, switch to it, verify isolation, cleanup + +**See**: `spec/CLAUDE.md` for testing patterns + +## Debugging Techniques + +**Check current tenant**: `Apartment::Tenant.current` + +**Inspect adapter**: `Apartment::Tenant.adapter.class` + +**List tenants**: `Apartment.tenant_names` + +**Enable logging**: `config.active_record_log = true` + +**PostgreSQL search path**: `SHOW search_path` in SQL console + +**See**: Inline code comments for context-specific debugging + +## Migration to v4 + +**v4 branch**: `man/spec-restart` + +**Major changes**: Connection pool per tenant (vs thread-local switching), fiber-safe via CurrentAttributes, immutable connection descriptors + +**Why v4**: Better performance (no switching overhead), true fiber safety, simpler mental model + +**Migration strategy**: Understand v3 architecture first (this branch), then contrast with v4 approach + +## Design Principles + +**Open for extension**: Users can create custom adapters and elevators without modifying gem. + +**Closed for modification**: Core logic shouldn't need changes for new use cases. + +**Fail fast**: Configuration errors raise at boot. Tenant not found raises at runtime. + +**Graceful degradation**: If rollback fails, fall back to default tenant rather than crash. + +**See**: `docs/architecture.md` for rationale + +## Getting Help + +**Issues**: https://github.com/rails-on-services/apartment/issues + +**Discussions**: https://github.com/rails-on-services/apartment/discussions + +**Code**: Read the actual implementation files - they're well-commented + +## Documentation Philosophy + +**This documentation focuses on WHY, not HOW**: +- Design decisions and trade-offs +- Architecture rationale +- Pitfalls and constraints +- References to actual source files + +**For HOW (implementation details)**: Read the well-commented source code in `lib/`. + +**For WHAT (API reference)**: See README.md and RDoc comments. diff --git a/README.md b/README.md index ede33171..d14ac1b2 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ Please note that our custom logger inherits from `ActiveRecord::LogSubscriber` s **Example log output:** - + ```ruby Apartment.configure do |config| diff --git a/Rakefile b/Rakefile index 0be50f8e..64605cbd 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,7 @@ # frozen_string_literal: true begin - require 'bundler' + require('bundler') rescue StandardError 'You must `gem install bundler` and `bundle install` to run rake tasks' end @@ -26,6 +26,7 @@ namespace :spec do end end +desc 'Start an interactive console with Apartment loaded' task :console do require 'pry' require 'apartment' @@ -39,15 +40,15 @@ namespace :db do namespace :test do case ENV.fetch('DATABASE_ENGINE', nil) when 'postgresql' - task prepare: %w[postgres:drop_db postgres:build_db] + task(prepare: %w[postgres:drop_db postgres:build_db]) when 'mysql' - task prepare: %w[mysql:drop_db mysql:build_db] + task(prepare: %w[mysql:drop_db mysql:build_db]) when 'sqlite' - task :prepare do + task(:prepare) do puts 'No need to prepare sqlite3 database' end else - task :prepare do + task(:prepare) do puts 'No database engine specified, skipping db:test:prepare' end end diff --git a/docs/adapters.md b/docs/adapters.md new file mode 100644 index 00000000..b8db2339 --- /dev/null +++ b/docs/adapters.md @@ -0,0 +1,177 @@ +# Apartment Adapters - Design & Architecture + +**Key files**: `lib/apartment/adapters/*.rb` + +## Purpose + +Adapters translate abstract tenant operations into database-specific implementations. Each database has fundamentally different isolation mechanisms, requiring separate strategies. + +## Design Decision: Why Adapter Pattern? + +**Problem**: PostgreSQL uses schemas, MySQL uses databases, SQLite uses files. A unified API across these different approaches requires abstraction. + +**Solution**: Adapter pattern with shared base class defining lifecycle, database-specific subclasses implementing mechanics. + +**Trade-off**: Adds complexity but enables multi-database support without polluting core logic. + +## Adapter Hierarchy + +See `lib/apartment/adapters/` for implementations: +- `abstract_adapter.rb` - Shared lifecycle, callbacks, error handling +- `postgresql_adapter.rb` - Schema-based isolation (3 variants) +- `mysql2_adapter.rb` - Database-per-tenant +- `sqlite3_adapter.rb` - File-per-tenant +- JDBC variants for JRuby + +## AbstractAdapter - Design Rationale + +**File**: `lib/apartment/adapters/abstract_adapter.rb` + +### Why Callbacks? + +Provides extension points for logging, notifications, analytics without modifying core adapter code. Users can hook into `:create` and `:switch` events. + +### Why Ensure Blocks in switch()? + +**Critical decision**: Always rollback to previous tenant, even if block raises. Prevents tenant context leakage across requests/jobs. If rollback fails, fall back to default tenant as last resort. + +**Alternative considered**: Let exceptions propagate without cleanup. Rejected because it leaves connections in wrong tenant state. + +### Why Query Cache Management? + +Rails disables query cache during connection establishment. Must explicitly preserve and restore state across tenant switches to maintain performance. + +### Why Separate Connection Handler? + +`SeparateDbConnectionHandler` prevents admin operations (CREATE/DROP DATABASE) from polluting the main application connection pool. Multi-server setups especially need this isolation. + +## PostgreSQL Adapters - Three Strategies + +**Files**: `postgresql_adapter.rb` (3 classes in one file) + +### 1. PostgresqlAdapter (Database-per-tenant) + +Rarely used. Most deployments use schemas instead. + +### 2. PostgresqlSchemaAdapter (Schema-based - Primary) + +**Why schemas?**: Single database, multiple namespaces. Fast switching via `SET search_path`. Scales to hundreds of tenants without connection overhead. + +**Key design decisions**: +- **Search path ordering**: Tenant schema first, then persistent schemas, then public. Tables resolve in order. +- **Persistent schemas**: Shared extensions (PostGIS, uuid-ossp) remain accessible across all tenants. +- **Excluded model handling**: Explicitly qualify table names with default schema to prevent tenant-based queries. + +**Trade-off**: Less isolation than separate databases, but massively better performance and scalability. + +### 3. PostgresqlSchemaFromSqlAdapter (pg_dump-based) + +**Why pg_dump instead of schema.rb?**: +- Handles PostgreSQL-specific features (extensions, custom types, constraints) that Rails schema dumper misses +- Required for PostGIS spatial types +- Necessary for complex production schemas + +**Why patch search_path in dump?**: pg_dump outputs assume specific search_path. Must rewrite SQL to target new tenant schema instead of source schema. + +**Why environment variable handling?**: pg_dump shell command reads PGHOST/PGUSER/etc from ENV. Must temporarily set, execute, then restore to avoid polluting global state. + +**Alternative considered**: Use Rails schema.rb. Rejected because it loses PostgreSQL-specific features. + +## MySQL Adapters - Database Isolation + +**Files**: `mysql2_adapter.rb`, `trilogy_adapter.rb` + +### Why Separate Databases? + +MySQL doesn't have PostgreSQL's robust schema support. Database-level isolation is the natural fit. + +**Implications**: +- Each switch establishes new connection to different database +- Connection pool per tenant (memory overhead) +- Practical limit of 10-50 concurrent tenants before connection exhaustion + +### Why Trilogy Adapter? + +Modern MySQL driver. Identical implementation to Mysql2Adapter, just different gem. + +### Multi-Server Support + +Hash-based tenant config allows different tenants on different MySQL servers. Enables horizontal scaling and geographic distribution. + +## SQLite Adapter - File-Based + +**File**: `sqlite3_adapter.rb` + +### Why File-Per-Tenant? + +SQLite is single-file database. Natural isolation is separate files. + +**Use case**: Testing, development, single-user apps. **Not** production multi-tenant. + +## Performance Characteristics + +**PostgreSQL schemas**: +- Switch latency: <1ms (SQL command) +- Scalability: 100+ tenants easily +- Memory: Constant (~50MB) + +**MySQL databases**: +- Switch latency: 10-50ms (connection establishment) +- Scalability: 10-50 tenants +- Memory: ~20MB per active tenant + +**SQLite files**: +- Switch latency: 5-20ms (file I/O) +- Scalability: Not recommended for concurrent users +- Memory: ~5MB per database + +## Adapter Selection Matrix + +| Database | Strategy | Speed | Scalability | Isolation | Best For | +|------------|--------------|-----------|-------------|-----------|-----------------------| +| PostgreSQL | Schemas | Very Fast | Excellent | Good | 100+ tenants | +| MySQL | Databases | Moderate | Good | Excellent | Complete isolation | +| SQLite | Files | Moderate | Poor | Excellent | Testing only | + +## Extension Points + +### Creating Custom Adapters + +**Why you might need this**: Supporting databases not yet implemented (Oracle, SQL Server, CockroachDB). + +**What to implement**: +1. Subclass `AbstractAdapter` +2. Define required methods: `create_tenant`, `connect_to_new`, `drop_command`, `current` +3. Register factory method in `lib/apartment/tenant.rb` + +**See**: Existing adapters for patterns. PostgreSQL is most complex, SQLite is simplest. + +## Common Pitfalls & Design Constraints + +### Why Transaction Handling in create_tenant? + +RSpec tests run in transactions. Must detect open transactions and avoid nested BEGIN/COMMIT to prevent errors. + +### Why Separate rescue_from per Adapter? + +Different databases raise different exceptions. PostgreSQL raises `PG::Error`, MySQL raises different errors. Each adapter specifies what to rescue. + +### Why environmentify()? + +Prevents tenant name collisions across Rails environments. `development_acme` vs `production_acme`. Optional but recommended for shared infrastructure. + +## Thread Safety + +**Critical**: Adapters stored in `Thread.current[:apartment_adapter]`. Each thread gets isolated adapter instance. + +**Implication**: Safe for multi-threaded servers (Puma), background jobs (Sidekiq). + +**Limitation**: Not fiber-safe. v4 refactor addresses this. + +## References + +- AbstractAdapter implementation: `lib/apartment/adapters/abstract_adapter.rb` +- PostgreSQL variants: `lib/apartment/adapters/postgresql_adapter.rb` +- MySQL variants: `lib/apartment/adapters/mysql2_adapter.rb`, `trilogy_adapter.rb` +- SQLite: `lib/apartment/adapters/sqlite3_adapter.rb` +- PostgreSQL documentation: https://www.postgresql.org/docs/current/ddl-schemas.html diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..6323dfe3 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,274 @@ +# Apartment v3 Architecture - Design Decisions + +**Core files**: `lib/apartment.rb`, `lib/apartment/tenant.rb` + +## Architectural Philosophy + +Apartment v3 uses **thread-local state** for tenant tracking. Each thread maintains its own adapter instance, enabling concurrent request handling without cross-contamination. + +**Critical design constraint**: This architecture is **not fiber-safe**. The v4 refactor addresses this limitation. + +## Core Design Patterns + +### 1. Adapter Pattern + +**Why**: Different databases require fundamentally different isolation strategies (PostgreSQL schemas vs MySQL databases vs SQLite files). + +**Implementation**: `AbstractAdapter` defines lifecycle, database-specific subclasses implement mechanics. + +**Trade-off**: Adds abstraction layer but enables multi-database support. + +**See**: `lib/apartment/adapters/` + +### 2. Delegation Pattern + +**Why**: Simplify public API while maintaining internal flexibility. + +**Implementation**: `Apartment::Tenant` delegates all operations to the thread-local adapter instance. + +**Benefit**: Swap adapter implementations without changing user-facing code. + +**See**: `lib/apartment/tenant.rb` - uses `def_delegators` + +### 3. Thread-Local Storage Pattern + +**Why**: Concurrent requests need isolated tenant contexts. + +**Implementation**: Adapter stored in `Thread.current[:apartment_adapter]`. + +**Safe for**: +- Multi-threaded web servers (Puma, Falcon) +- Background job processors (Sidekiq with threading) +- Concurrent requests to different tenants + +**Unsafe for**: +- Fiber-based async frameworks (fibers share thread storage) +- Manual thread management with shared state + +**Alternative considered**: Global state with mutex locking. Rejected due to contention and complexity. + +**See**: `Apartment::Tenant.adapter` method in `tenant.rb` + +### 4. Callback Pattern + +**Why**: Users need extension points without modifying gem code. + +**Implementation**: ActiveSupport::Callbacks on `:create` and `:switch` events. + +**Use cases**: Logging, notifications, analytics, APM integration. + +**See**: Callback definitions in `AbstractAdapter` class + +### 5. Strategy Pattern (Elevators) + +**Why**: Different applications need different tenant resolution mechanisms (subdomain, domain, header, session). + +**Implementation**: Pluggable Rack middleware with customizable `parse_tenant_name`. + +**Benefit**: Easy to add custom strategies without changing core. + +**See**: `lib/apartment/elevators/` + +## Component Interaction + +### Request Processing Flow + +**Path**: Rack request → Elevator → Adapter → Database + +**Key decision points**: +1. **Elevator positioning**: Must be before session/auth middleware. Why? Tenant context must be established before session data loads, otherwise wrong tenant's sessions leak. + +2. **Automatic cleanup**: `ensure` blocks in `switch()` guarantee tenant rollback even on exceptions. Why? Prevents connection staying in wrong tenant after errors. + +3. **Query cache management**: Explicitly preserve across switches. Why? Rails disables during connection establishment; must manually restore to maintain performance. + +**See**: `lib/apartment/elevators/generic.rb` - base middleware pattern + +### Tenant Creation Flow + +**Path**: User code → Adapter → Database → Schema import → Seeding + +**Key decisions**: +1. **Callback execution**: Wraps entire creation in callbacks. Why? Logging and notifications must capture the complete operation. + +2. **Switch during creation**: Import and seed run in tenant context. Why? Schema loading must target new tenant, not default. + +3. **Transaction handling**: Detect existing transactions (RSpec). Why? Avoid nested transactions that PostgreSQL rejects. + +**See**: `AbstractAdapter#create` method + +### Configuration Resolution + +**Why dynamic tenant lists?**: Tenants change at runtime (new signups, deletions). Static lists become stale. + +**Implementation**: `tenant_names` can be callable (proc/lambda) that queries database. + +**Critical handling**: Rescue `ActiveRecord::StatementInvalid` during boot. Why? Table might not exist yet (migrations pending). Return empty array to allow app to start. + +**See**: `Apartment.extract_tenant_config` method + +## Data Flow Differences by Database + +### PostgreSQL Schema Strategy + +**Mechanism**: Single connection pool, `SET search_path` per query. + +**Why this works**: PostgreSQL schemas are namespaces. Queries resolve to first matching table in search path. + +**Memory efficiency**: Connection pool shared across all tenants. Only schema metadata grows with tenant count. + +**Performance**: Sub-millisecond switching (simple SQL command). + +**Limitation**: All tenants in same database. Backup/restore is database-wide. + +### MySQL Database Strategy + +**Mechanism**: Separate connection pool per tenant. + +**Why different from PostgreSQL**: MySQL lacks robust schema support. Database is natural isolation unit. + +**Memory cost**: Each active tenant requires connection pool (~20MB). + +**Performance**: Slower switching (connection establishment overhead). + +**Benefit**: Complete isolation. Can backup/restore individual tenants. + +### SQLite File Strategy + +**Mechanism**: Separate database file per tenant. + +**Why file-based**: SQLite is single-file by design. + +**Use case**: Testing and development only. Concurrent writes cause locking issues. + +## Memory Management + +### PostgreSQL (Shared Pool) +- Constant base: ~50MB for connection pool +- Growth: Only schema metadata (minimal) +- Scales to: 100+ tenants easily + +### MySQL (Pool Per Tenant) +- Base per tenant: ~20MB connection pool +- Growth: Linear with active tenant count +- Consider: LRU cache for connection pools (not implemented in v3) + +## Thread Safety Analysis + +### What's Safe + +**Multi-threaded request handling**: Each thread gets isolated adapter instance via `Thread.current`. + +**Concurrent tenant access**: Thread 1 can be in tenant_a while Thread 2 is in tenant_b without interference. + +**Background jobs**: Sidekiq workers are threads, get their own adapters. + +### What's Unsafe + +**Fiber switching**: Fibers within a thread share `Thread.current`. Fiber-based async (EventMachine, async gem) will have cross-contamination. + +**Manual thread pooling with shared state**: Don't share adapter instances across threads. + +**Solution**: v4 refactor uses `ActiveSupport::CurrentAttributes` which is fiber-safe. + +## Error Handling Philosophy + +### Fail Fast vs Graceful Degradation + +**Tenant not found**: Raise exception. Why? Better to show error than serve wrong data. + +**Tenant creation collision**: Raise exception. Why? Concurrent creation attempts indicate application bug. + +**Rollback failure**: Fall back to default tenant. Why? Better to serve default data than crash entire request. + +**Configuration errors**: Raise on boot. Why? Invalid config should prevent startup, not cause runtime failures. + +## Excluded Models - Design Rationale + +**Problem**: Some models (User, Company) exist globally, not per-tenant. + +**Solution**: Establish separate connections that bypass tenant switching. + +**Implementation**: PostgreSQL explicitly qualifies table names (`public.users`). MySQL uses separate connection. + +**Why not conditional logic?**: Separate connections are cleaner than "if excluded, do X else do Y" throughout codebase. + +**Limitation**: `has_and_belongs_to_many` doesn't work with excluded models. Must use `has_many :through` instead. + +**See**: `AbstractAdapter#process_excluded_models` method + +## Configuration Design + +### Why Callable tenant_names? + +**Problem**: Static arrays become stale as tenants are created/deleted. + +**Solution**: Accept proc/lambda that queries database dynamically. + +**Trade-off**: Extra query on each access. Consider caching. + +### Why Hash Format for Multi-Server? + +**Problem**: Different tenants might live on different database servers. + +**Solution**: Hash maps tenant name to full connection config. + +**Benefit**: Enables horizontal scaling and geographic distribution. + +**See**: README.md examples and `Apartment.db_config_for` method + +## Performance Design Decisions + +### Why Query Cache Preservation? + +**Impact**: 10-30% performance improvement on cache-heavy workloads. + +**Cost**: Extra bookkeeping on every switch. + +**Decision**: Worth it. Query cache is critical for Rails performance. + +### Why Connection Verification? + +**call to verify!**: Ensures connection is live after establishment. + +**Why needed**: Stale connections from pool can cause mysterious failures. + +**Cost**: Extra network round-trip, but prevents worse failures. + +## Extension Points + +### For Users + +1. **Custom elevators**: Subclass `Generic`, override `parse_tenant_name` +2. **Callbacks**: Hook into `:create` and `:switch` events +3. **Custom adapters**: Subclass `AbstractAdapter` for new databases + +### Design Principle + +**Open for extension, closed for modification**: Users can add behavior without changing gem code. + +## Limitations & Known Issues + +### v3 Constraints + +1. **Thread-local only**: Not fiber-safe +2. **Single adapter type**: Can't mix PostgreSQL schemas and MySQL databases in one app +3. **No horizontal sharding**: Each adapter connects to single database cluster +4. **Global excluded models**: Can't have different exclusions per tenant + +### Why These Exist + +Historical decisions made before newer Rails features (sharding, CurrentAttributes) existed. + +### v4 Improvements + +The `man/spec-restart` branch refactor addresses most limitations via connection-pool-per-tenant architecture. + +## References + +- Main module: `lib/apartment.rb` +- Public API: `lib/apartment/tenant.rb` +- Adapters: `lib/apartment/adapters/*.rb` +- Elevators: `lib/apartment/elevators/*.rb` +- Thread storage: Ruby documentation on `Thread.current` +- Rails connection pooling: Rails guides diff --git a/docs/elevators.md b/docs/elevators.md new file mode 100644 index 00000000..01e30511 --- /dev/null +++ b/docs/elevators.md @@ -0,0 +1,226 @@ +# Apartment Elevators - Middleware Design + +**Key files**: `lib/apartment/elevators/*.rb` + +## Purpose + +Elevators are Rack middleware that automatically detect tenant from HTTP requests and establish tenant context before application code runs. + +**Name metaphor**: Like elevators transport you between building floors, these middleware transport requests between tenant contexts. + +## Design Decision: Why Middleware? + +**Problem**: Manual tenant switching in controllers is error-prone. Easy to forget, creates boilerplate. + +**Solution**: Rack middleware intercepts all requests, switches tenant automatically based on request attributes. + +**Trade-off**: Adds middleware overhead (minimal) but eliminates entire class of bugs. + +## Critical Positioning Requirement + +**Rule**: Elevators MUST be positioned before session/authentication middleware. + +**Why**: Session data is tenant-specific. Loading session before establishing tenant context causes data leakage. + +**How to verify**: `Rails.application.middleware` lists order. Elevator should appear before `ActionDispatch::Session` and `Warden::Manager`. + +**See**: Configuration examples in README.md + +## Available Elevator Strategies + +**Files**: All in `lib/apartment/elevators/` + +### Subdomain Elevator + +**File**: `subdomain.rb` + +**Strategy**: Extract first subdomain as tenant name. + +**Why PublicSuffix gem?**: Handles international TLDs correctly. `example.co.uk` has TLD `.co.uk`, not just `.uk`. + +**Exclusion mechanism**: Configurable list of ignored subdomains (www, admin, api). Returns nil for excluded, which uses default tenant. + +**Why class-level exclusions?**: Shared across all instances. Set once in initializer. + +### Domain Elevator + +**File**: `domain.rb` + +**Strategy**: Use domain name (excluding www and TLD) as tenant. + +**Use case**: When domain itself identifies tenant (acme.com vs widgets.com), not subdomain. + +### Host Elevator + +**File**: `host.rb` + +**Strategy**: Use full hostname as tenant name. + +**Ignored subdomains**: Optional configuration to strip www/app from beginning. + +**Use case**: Custom domains where full hostname matters. + +### HostHash Elevator + +**File**: `host_hash.rb` + +**Strategy**: Direct hash mapping from hostname to tenant name. + +**Why needed?**: When hostname→tenant mapping is arbitrary or complex. + +**Trade-off**: Requires explicit configuration per tenant. Not dynamic. + +### Generic Elevator + +**File**: `generic.rb` + +**Purpose**: Base class for custom elevators. Accept Proc for inline logic or subclass for complex scenarios. + +**Extension point**: Override `parse_tenant_name(request)` method. + +**See**: Examples in file comments + +## Design Patterns + +### Why Return nil for Excluded? + +Returning nil (not default_tenant name) allows Apartment core to handle fallback logic. Separation of concerns. + +### Why ensure Block in call()? + +Guarantees tenant cleanup even if application code raises. Prevents request bleeding into next request's tenant context. + +### Why Rack::Request Object? + +Standard interface. Access to host, headers, session, cookies. Database-independent. + +## Request Lifecycle + +**Sequence**: +1. Rack request enters application +2. Elevator middleware intercepts (positioned early) +3. Calls `parse_tenant_name(request)` - strategy determines tenant +4. Calls `Apartment::Tenant.switch(tenant) { @app.call(env) }` +5. Application processes in tenant context +6. Ensure block resets tenant after response + +**Critical**: Step 6 happens even on exceptions. Why? Prevent tenant leakage. + +## Performance Considerations + +### Caching Tenant Lookups + +If `parse_tenant_name` does database queries, consider caching: +- Subdomain→tenant mapping cached for 5-10 minutes +- Invalidate cache when tenants created/deleted + +**Why needed?**: Elevator runs on EVERY request. Database query per request adds latency. + +**Not implemented in v3**: Users must implement caching in custom elevators. + +### Why Not Cache in Gem? + +Different applications have different caching strategies (Redis, Memcached, Rails.cache). Prescribing one limits flexibility. + +## Error Handling Philosophy + +**Default behavior**: Exceptions propagate. TenantNotFound crashes request. + +**Rationale**: Better to show error than serve wrong data or default data without user realizing. + +**Alternative**: Custom elevator can rescue and return 404/redirect. + +**See**: docs/adapters.md for error hierarchy + +## Extension Points + +### Creating Custom Elevators + +**Two approaches**: + +1. **Inline Proc**: For simple logic, pass Proc to Generic +2. **Subclass**: For complex logic, override `parse_tenant_name` + +**When to subclass**: +- Multi-strategy fallback (header → session → subdomain) +- Database lookups with caching +- Complex validation/transformation logic + +**See**: `generic.rb` for base implementation + +### Common Custom Patterns + +**Header-based**: API requests with `X-Tenant-ID` header +**Session-based**: Tenant selected in login flow, stored in session +**API key-based**: Database lookup from authentication token +**Hybrid**: Try multiple strategies in priority order + +## Common Pitfalls + +### Pitfall: Elevator After Session Middleware + +**Symptom**: Wrong tenant's session data appearing + +**Cause**: Session loaded before tenant switched + +**Fix**: Reposition elevator before session middleware + +### Pitfall: Database Queries in parse_tenant_name + +**Symptom**: Slow request times, database overload + +**Cause**: Query on every request without caching + +**Fix**: Implement caching layer + +### Pitfall: Not Handling Exclusions + +**Symptom**: www.example.com creates "www" tenant, admin pages switch tenants + +**Cause**: No exclusion configuration + +**Fix**: Configure `excluded_subdomains` + +### Pitfall: Returning Tenant Name That Doesn't Exist + +**Symptom**: TenantNotFound errors + +**Cause**: No validation before switching + +**Fix**: Add existence check in custom elevator or handle error + +## Testing Elevators + +**Challenge**: Elevators are middleware, not models/controllers. + +**Solution**: Use `Rack::MockRequest` to simulate requests with different hosts. + +**Pattern**: Mock `Apartment::Tenant.switch` to verify correct tenant extracted. + +**See**: `spec/unit/elevators/` for examples + +## Integration with Background Jobs + +**Important**: Elevators only affect web requests. Background jobs need separate tenant handling. + +**Solution**: Job frameworks must capture and restore tenant (apartment-sidekiq gem). + +**Why separate?**: Jobs aren't HTTP requests. No Rack middleware involved. + +## Multi-Elevator Setup + +**Possible but discouraged**: Multiple elevators in middleware stack. + +**Why discouraged**: Last elevator wins. Complex, hard to debug. + +**Alternative**: Single custom elevator with multi-strategy logic. + +## References + +- Generic base: `lib/apartment/elevators/generic.rb` +- Subdomain implementation: `lib/apartment/elevators/subdomain.rb` +- Domain implementation: `lib/apartment/elevators/domain.rb` +- Host implementations: `lib/apartment/elevators/host.rb`, `host_hash.rb` +- First subdomain: `lib/apartment/elevators/first_subdomain.rb` +- Rack middleware: https://github.com/rack/rack/wiki/Middleware +- PublicSuffix gem: https://github.com/weppos/publicsuffix-ruby diff --git a/documentation/images/log_example.png b/docs/images/log_example.png similarity index 100% rename from documentation/images/log_example.png rename to docs/images/log_example.png diff --git a/gemfiles/rails_6_1_jdbc_mysql.gemfile b/gemfiles/rails_6_1_jdbc_mysql.gemfile deleted file mode 100644 index 37e28ec9..00000000 --- a/gemfiles/rails_6_1_jdbc_mysql.gemfile +++ /dev/null @@ -1,27 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "appraisal", "~> 2.3" -gem "bundler", "< 3.0" -gem "pry", "~> 0.13" -gem "rake", "< 14.0" -gem "rspec", "~> 3.10" -gem "rspec_junit_formatter", "~> 0.4" -gem "rspec-rails", ">= 6.1.0", "< 8.1" -gem "rubocop", "~> 1.12" -gem "rubocop-performance", "~> 1.10" -gem "rubocop-rails", "~> 2.10" -gem "rubocop-rake", "~> 0.5" -gem "rubocop-rspec", "~> 3.1" -gem "rubocop-thread_safety", "~> 0.4" -gem "simplecov", require: false -gem "rails", "~> 6.1.0" - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 61.3" - gem "activerecord-jdbcmysql-adapter", "~> 61.3" - gem "jdbc-mysql" -end - -gemspec path: "../" diff --git a/gemfiles/rails_6_1_jdbc_postgresql.gemfile b/gemfiles/rails_6_1_jdbc_postgresql.gemfile deleted file mode 100644 index 4a0af24e..00000000 --- a/gemfiles/rails_6_1_jdbc_postgresql.gemfile +++ /dev/null @@ -1,27 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "appraisal", "~> 2.3" -gem "bundler", "< 3.0" -gem "pry", "~> 0.13" -gem "rake", "< 14.0" -gem "rspec", "~> 3.10" -gem "rspec_junit_formatter", "~> 0.4" -gem "rspec-rails", ">= 6.1.0", "< 8.1" -gem "rubocop", "~> 1.12" -gem "rubocop-performance", "~> 1.10" -gem "rubocop-rails", "~> 2.10" -gem "rubocop-rake", "~> 0.5" -gem "rubocop-rspec", "~> 3.1" -gem "rubocop-thread_safety", "~> 0.4" -gem "simplecov", require: false -gem "rails", "~> 6.1.0" - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 61.3" - gem "activerecord-jdbcpostgresql-adapter", "~> 61.3" - gem "jdbc-postgres" -end - -gemspec path: "../" diff --git a/gemfiles/rails_6_1_jdbc_sqlite3.gemfile b/gemfiles/rails_6_1_jdbc_sqlite3.gemfile deleted file mode 100644 index fc10e995..00000000 --- a/gemfiles/rails_6_1_jdbc_sqlite3.gemfile +++ /dev/null @@ -1,27 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "appraisal", "~> 2.3" -gem "bundler", "< 3.0" -gem "pry", "~> 0.13" -gem "rake", "< 14.0" -gem "rspec", "~> 3.10" -gem "rspec_junit_formatter", "~> 0.4" -gem "rspec-rails", ">= 6.1.0", "< 8.1" -gem "rubocop", "~> 1.12" -gem "rubocop-performance", "~> 1.10" -gem "rubocop-rails", "~> 2.10" -gem "rubocop-rake", "~> 0.5" -gem "rubocop-rspec", "~> 3.1" -gem "rubocop-thread_safety", "~> 0.4" -gem "simplecov", require: false -gem "rails", "~> 6.1.0" - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 61.3" - gem "activerecord-jdbcsqlite3-adapter", "~> 61.3" - gem "jdbc-sqlite3" -end - -gemspec path: "../" diff --git a/gemfiles/rails_6_1_mysql.gemfile b/gemfiles/rails_8_1_mysql.gemfile similarity index 95% rename from gemfiles/rails_6_1_mysql.gemfile rename to gemfiles/rails_8_1_mysql.gemfile index 3a89dcf1..13f74069 100644 --- a/gemfiles/rails_6_1_mysql.gemfile +++ b/gemfiles/rails_8_1_mysql.gemfile @@ -16,7 +16,7 @@ gem "rubocop-rake", "~> 0.5" gem "rubocop-rspec", "~> 3.1" gem "rubocop-thread_safety", "~> 0.4" gem "simplecov", require: false -gem "rails", "~> 6.1.0" +gem "rails", "~> 8.1.0" gem "mysql2", "~> 0.5" gemspec path: "../" diff --git a/gemfiles/rails_6_1_postgresql.gemfile b/gemfiles/rails_8_1_postgresql.gemfile similarity index 91% rename from gemfiles/rails_6_1_postgresql.gemfile rename to gemfiles/rails_8_1_postgresql.gemfile index 77617c8d..954f7826 100644 --- a/gemfiles/rails_6_1_postgresql.gemfile +++ b/gemfiles/rails_8_1_postgresql.gemfile @@ -16,7 +16,7 @@ gem "rubocop-rake", "~> 0.5" gem "rubocop-rspec", "~> 3.1" gem "rubocop-thread_safety", "~> 0.4" gem "simplecov", require: false -gem "rails", "~> 6.1.0" -gem "pg", "~> 1.5" +gem "rails", "~> 8.1.0" +gem "pg", "~> 1.6.0" gemspec path: "../" diff --git a/gemfiles/rails_6_1_sqlite3.gemfile b/gemfiles/rails_8_1_sqlite3.gemfile similarity index 91% rename from gemfiles/rails_6_1_sqlite3.gemfile rename to gemfiles/rails_8_1_sqlite3.gemfile index 7a85dfbb..d0994362 100644 --- a/gemfiles/rails_6_1_sqlite3.gemfile +++ b/gemfiles/rails_8_1_sqlite3.gemfile @@ -16,7 +16,7 @@ gem "rubocop-rake", "~> 0.5" gem "rubocop-rspec", "~> 3.1" gem "rubocop-thread_safety", "~> 0.4" gem "simplecov", require: false -gem "rails", "~> 6.1.0" -gem "sqlite3", "~> 1.4" +gem "rails", "~> 8.1.0" +gem "sqlite3", "~> 2.8" gemspec path: "../" diff --git a/lib/apartment.rb b/lib/apartment.rb index 6fd7c197..b4d28415 100644 --- a/lib/apartment.rb +++ b/lib/apartment.rb @@ -41,7 +41,7 @@ def connection_config # configure apartment with available options def configure - yield self if block_given? + yield(self) if block_given? end def tenant_names @@ -57,7 +57,7 @@ def tld_length=(_) end def db_config_for(tenant) - (tenants_with_config[tenant] || connection_config) + tenants_with_config[tenant] || connection_config end # Whether or not db:migrate should also migrate tenants @@ -68,9 +68,10 @@ def db_migrate_tenants @db_migrate_tenants = true end - # How to handle tenant missing on db:migrate - # defaults to :rescue_exception - # available options: rescue_exception, raise_exception, create_tenant + # How to handle missing tenants during db:migrate + # :rescue_exception (default) - Log error, continue with other tenants + # :raise_exception - Stop migration immediately + # :create_tenant - Automatically create missing tenant and migrate def db_migrate_tenant_missing_strategy valid = %i[rescue_exception raise_exception create_tenant] value = @db_migrate_tenant_missing_strategy || :rescue_exception @@ -80,7 +81,7 @@ def db_migrate_tenant_missing_strategy key_name = 'config.db_migrate_tenant_missing_strategy' opt_names = valid.join(', ') - raise ApartmentError, "Option #{value} not valid for `#{key_name}`. Use one of #{opt_names}" + raise(ApartmentError, "Option #{value} not valid for `#{key_name}`. Use one of #{opt_names}") end # Default to empty array @@ -126,14 +127,19 @@ def reset def extract_tenant_config return {} unless @tenant_names + # Execute callable (proc/lambda) to get dynamic tenant list from database values = @tenant_names.respond_to?(:call) ? @tenant_names.call : @tenant_names - unless values.is_a? Hash - values = values.each_with_object({}) do |tenant, hash| - hash[tenant] = connection_config + + # Normalize arrays to hash format (tenant_name => connection_config) + unless values.is_a?(Hash) + values = values.index_with do |_tenant| + connection_config end end values.with_indifferent_access rescue ActiveRecord::StatementInvalid + # Database query failed (table doesn't exist yet, connection issue) + # Return empty hash to allow app to boot without tenants {} end end diff --git a/lib/apartment/CLAUDE.md b/lib/apartment/CLAUDE.md new file mode 100644 index 00000000..8ec0ac70 --- /dev/null +++ b/lib/apartment/CLAUDE.md @@ -0,0 +1,300 @@ +# lib/apartment/ - Core Implementation Directory + +This directory contains the core implementation of Apartment v3's multi-tenancy system. + +## Directory Structure + +``` +lib/apartment/ +├── adapters/ # Database-specific tenant isolation strategies +├── active_record/ # ActiveRecord patches and extensions +├── elevators/ # Rack middleware for automatic tenant switching +├── patches/ # Ruby/Rails core patches +├── tasks/ # Rake task utilities +├── console.rb # Rails console tenant switching utilities +├── custom_console.rb # Enhanced console with tenant prompts +├── deprecation.rb # Deprecation warnings configuration +├── log_subscriber.rb # ActiveSupport instrumentation for logging +├── migrator.rb # Tenant-specific migration runner +├── model.rb # ActiveRecord model extensions for excluded models +├── railtie.rb # Rails initialization and integration +├── tenant.rb # Public API facade for tenant operations +└── version.rb # Gem version constant +``` + +## Core Files + +### tenant.rb - Public API Facade + +**Purpose**: Main entry point for all tenant operations. Delegates to appropriate adapter. + +**Key methods**: +- `create(tenant)` - Create new tenant +- `drop(tenant)` - Delete tenant +- `switch(tenant)` - Switch to tenant (block-based) +- `switch!(tenant)` - Immediate switch (no block) +- `current` - Get current tenant name +- `reset` - Return to default tenant +- `each` - Iterate over all tenants + +**Adapter delegation pattern**: Uses `Forwardable` to delegate all operations to thread-local adapter instance. See delegation setup in `tenant.rb`. + +**Thread-local storage**: Each thread maintains its own adapter via `Thread.current[:apartment_adapter]`. See `Apartment::Tenant.adapter` method for auto-detection logic. + +### railtie.rb - Rails Integration + +**Purpose**: Integrate Apartment with Rails initialization lifecycle. + +**Responsibilities**: +1. **Configuration loading**: Load `config/initializers/apartment.rb` +2. **Adapter initialization**: Call `Apartment::Tenant.init` after Rails boot +3. **Console enhancement**: Add tenant switching helpers to Rails console +4. **Rake task loading**: Load Apartment rake tasks +5. **ActiveRecord instrumentation**: Set up logging subscriber + +**Key integration points**: See Rails integration hooks in `railtie.rb` (`after_initialize`, `rake_tasks`, `console`). + +**Excluded models initialization**: The railtie ensures excluded models establish separate connections after Rails boots but before the application serves requests. See excluded model setup in `railtie.rb`. + +### console.rb / custom_console.rb - Interactive Debugging + +**console.rb**: Basic console helpers +**custom_console.rb**: Enhanced prompt showing current tenant + +**Features**: +- Display current tenant in prompt +- Quick switching helpers +- Tenant listing commands + +**Implementation**: See `console.rb` and `custom_console.rb` for prompt customization and helper methods. + +### migrator.rb - Tenant Migration Runner + +**Purpose**: Run migrations across all tenants. + +**Key functionality**: +- Detect pending migrations per tenant +- Run migrations in tenant context +- Handle migration failures gracefully +- Support parallel migration execution + +**Integration**: Used by `rake apartment:migrate` task. See migration coordination logic in `migrator.rb` and task definitions in `tasks/enhancements.rake`. + +**Parallel execution**: If `config.parallel_migration_threads > 0`, spawns threads to migrate multiple tenants concurrently. See parallel execution logic in `migrator.rb`. + +### model.rb - Excluded Model Behavior + +**Purpose**: Provide base module/behavior for excluded models. + +**Functionality**: +- Establish separate connection to default database +- Bypass tenant switching +- Maintain global data across tenants + +**Behavior**: When a model is in `Apartment.excluded_models`, it automatically establishes connection to default database and bypasses tenant switching. See connection handling in `model.rb` and `AbstractAdapter#process_excluded_models`. + +### log_subscriber.rb - Instrumentation + +**Purpose**: Subscribe to ActiveSupport notifications for logging tenant operations. + +**Events logged**: +- Tenant creation +- Tenant switching +- Tenant deletion +- Migration execution + +**Configuration**: Set `config.active_record_log = true` to enable. See event subscriptions in `log_subscriber.rb` and configuration options in `lib/apartment.rb`. + +### version.rb - Version Management + +**Purpose**: Define gem version constant. Used by gemspec and for version checking. See `version.rb`. + +### deprecation.rb - Deprecation Warnings + +**Purpose**: Configure ActiveSupport::Deprecation for Apartment. + +**Implementation**: Sets up deprecation warnings targeting v4.0. See `deprecation.rb` for DEPRECATOR constant. + +## Subdirectories + +### adapters/ + +Database-specific implementations of tenant operations. See `lib/apartment/adapters/CLAUDE.md`. + +**Key files**: +- `abstract_adapter.rb` - Base adapter with common logic +- `postgresql_adapter.rb` - PostgreSQL schema-based isolation +- `mysql2_adapter.rb` - MySQL database-based isolation +- `sqlite3_adapter.rb` - SQLite file-based isolation + +### active_record/ + +ActiveRecord patches and extensions for tenant-aware behavior. See `lib/apartment/active_record/CLAUDE.md`. + +**Key files**: +- `connection_handling.rb` - Patches to AR connection management +- `schema_migration.rb` - Tenant-aware schema_migrations table +- `postgresql_adapter.rb` - PostgreSQL-specific AR extensions +- `postgres/schema_dumper.rb` - Custom schema dumping (Rails 7.1+) + +### elevators/ + +Rack middleware for automatic tenant detection. See `lib/apartment/elevators/CLAUDE.md`. + +**Key files**: +- `generic.rb` - Base elevator with customizable logic +- `subdomain.rb` - Switch based on subdomain +- `domain.rb` - Switch based on domain +- `host.rb` - Switch based on full hostname +- `host_hash.rb` - Switch based on hostname→tenant mapping + +### tasks/ + +Rake task utilities and enhancements. + +**Key files**: +- `enhancements.rb` - Rake task definitions (migrate, seed, create, drop) +- `task_helper.rb` - Shared task utilities + +## Data Flow + +### Tenant Creation Flow + +1. User calls `Apartment::Tenant.create('acme')` +2. Delegates to adapter which executes callbacks, creates schema/database, imports schema, optionally runs seeds +3. Returns to user code + +**See**: `Apartment::Tenant.create` and `AbstractAdapter#create` for orchestration. + +### Tenant Switching Flow + +1. User calls `Apartment::Tenant.switch('acme') { ... }` +2. Adapter stores current tenant, switches connection, yields to block, ensures rollback in ensure clause +3. Returns to user code with tenant automatically restored + +**See**: `AbstractAdapter#switch` method for implementation. + +### Request Processing Flow (with Elevator) + +1. HTTP Request arrives +2. Elevator extracts tenant, calls `Apartment::Tenant.switch` +3. Application processes in tenant context +4. Elevator ensures tenant reset + +**See**: `elevators/generic.rb` for middleware pattern. + +## Thread Safety + +### Current Implementation (v3) + +**Thread-local adapter storage**: Uses `Thread.current[:apartment_adapter]` for isolation. + +**Implications**: +- ✅ Each thread has isolated tenant context +- ✅ Safe for multi-threaded servers (Puma) +- ✅ Safe for background jobs (Sidekiq) +- ❌ NOT fiber-safe (fibers share thread storage) +- ❌ Global mutable state within thread + +**See**: `Apartment::Tenant.adapter` method for thread-local implementation. + +## Configuration Integration + +### Loading Process + +1. Rails boots +2. `config/initializers/apartment.rb` loads +3. `Apartment.configure` executes +4. Configuration stored in module instance variables +5. `Railtie.after_initialize` fires +6. `Apartment::Tenant.init` called +7. Excluded models processed +8. Adapter initialized (lazy, on first use) + +**See**: Configuration methods in `lib/apartment.rb` and initialization hooks in `railtie.rb`. + +### Configuration Access + +Available configuration methods: `Apartment.tenant_names`, `Apartment.excluded_models`, `Apartment.connection_class`, `Apartment.db_migrate_tenants`. See `lib/apartment.rb` for all configuration options. + +## Error Handling + +### Exception Hierarchy + +- `Apartment::ApartmentError` - Base exception for all Apartment errors +- `Apartment::TenantNotFound` - Raised when switching to nonexistent tenant +- `Apartment::TenantExists` - Raised when creating duplicate tenant + +**See**: Adapter `connect_to_new` methods raise `TenantNotFound`. See `AbstractAdapter#switch` for error handling. + +### Automatic Cleanup + +The `switch` method guarantees cleanup via ensure block, falling back to default tenant if rollback fails. See `AbstractAdapter#switch` for implementation. + +## Extending Apartment + +### Adding Custom Adapter + +1. Create file: `lib/apartment/adapters/custom_adapter.rb` +2. Subclass `AbstractAdapter` +3. Implement required methods +4. Add factory method to `tenant.rb` + +See `docs/adapters.md` for details. + +### Adding Custom Elevator + +1. Create file: `app/middleware/custom_elevator.rb` +2. Subclass `Apartment::Elevators::Generic` +3. Override `parse_tenant_name(request)` +4. Add to middleware stack in `config/application.rb` + +See `docs/elevators.md` for details. + +### Adding Custom Callbacks + +Use ActiveSupport::Callbacks to hook into `:create` and `:switch` events. See callback definitions in `AbstractAdapter` and README.md for configuration examples. + +## Testing Considerations + +### RSpec Integration + +Always reset tenant context in before/after hooks to prevent test isolation issues. See `spec/support/` for helper modules and `spec/spec_helper.rb` for configuration patterns. + +### Creating Test Tenants + +Create helpers for tenant lifecycle management to avoid duplication. See `spec/support/apartment_helper.rb` for patterns. + +## Debugging Tips + +### Enable Verbose Logging + +Set `config.active_record_log = true` in initializer. See logging configuration in `lib/apartment.rb`. + +### Check Current Tenant + +Use `Apartment::Tenant.current` to inspect current tenant context. + +### Inspect Adapter + +Access `Apartment::Tenant.adapter` to inspect adapter class and configuration. + +### Verify Excluded Models + +Iterate `Apartment.excluded_models` and check each model's connection configuration. + +## Common Pitfalls + +1. **Not using block-based switching**: Always use `switch` with block, not `switch!` +2. **Elevator positioning**: Must be before session/auth middleware +3. **Excluded model relationships**: Use `has_many :through`, not `has_and_belongs_to_many` +4. **Thread safety assumptions**: Remember adapters are thread-local, not global +5. **Forgetting to reset**: In tests, always reset tenant in teardown + +## References + +- Main README: `/README.md` +- Architecture docs: `/docs/architecture.md` +- Adapter docs: `/docs/adapters.md` +- Elevator docs: `/docs/elevators.md` +- ActiveRecord connection handling: Rails guides diff --git a/lib/apartment/adapters/CLAUDE.md b/lib/apartment/adapters/CLAUDE.md new file mode 100644 index 00000000..bb9cfaf6 --- /dev/null +++ b/lib/apartment/adapters/CLAUDE.md @@ -0,0 +1,314 @@ +# lib/apartment/adapters/ - Database Adapter Implementations + +This directory contains database-specific implementations of tenant isolation strategies. + +## Purpose + +Adapters translate abstract tenant operations (create, switch, drop) into database-specific SQL commands and connection management. + +## File Structure + +``` +adapters/ +├── abstract_adapter.rb # Base class with shared logic +├── postgresql_adapter.rb # PostgreSQL schema-based isolation +├── postgis_adapter.rb # PostgreSQL with PostGIS extensions +├── mysql2_adapter.rb # MySQL database-based isolation (mysql2 gem) +├── trilogy_adapter.rb # MySQL database-based isolation (trilogy gem) +├── sqlite3_adapter.rb # SQLite file-based isolation +├── abstract_jdbc_adapter.rb # Base for JDBC adapters (JRuby) +├── jdbc_postgresql_adapter.rb # JDBC PostgreSQL adapter +└── jdbc_mysql_adapter.rb # JDBC MySQL adapter +``` + +## Adapter Hierarchy + +``` +AbstractAdapter +├── PostgresqlAdapter +│ ├── PostgisAdapter (PostgreSQL + spatial extensions) +│ └── JdbcPostgresqlAdapter (JDBC for JRuby) +├── Mysql2Adapter +│ ├── TrilogyAdapter (alternative MySQL driver) +│ └── JdbcMysqlAdapter (JDBC for JRuby) +└── Sqlite3Adapter +``` + +## AbstractAdapter - Base Implementation + +**Location**: `abstract_adapter.rb` + +### Responsibilities + +1. **Common tenant lifecycle logic**: + - Callback execution (`:create`, `:switch`) + - Schema import coordination + - Seed data execution + - Exception handling + +2. **Excluded model management**: + - Establish separate connections for excluded models + - Ensure they bypass tenant switching + +3. **Helper methods**: + - `environmentify(tenant)` - Add Rails env to tenant name + - `seed_data` - Load seeds.rb in tenant context + - `each(tenants)` - Iterate over tenants + +### Abstract Methods (Subclasses Must Implement) + +- `create_tenant(tenant)` - Create the tenant (schema/database/file) +- `connect_to_new(tenant)` - Switch to tenant (change connection or search_path) +- `drop_command(conn, tenant)` - Drop the tenant +- `current` - Get current tenant name + +**See**: Abstract method definitions in `abstract_adapter.rb`. + +### Common Logic Provided + +**Tenant creation**: Runs callbacks, creates tenant via subclass, switches context, imports schema, optionally seeds data. See `AbstractAdapter#create` method. + +**Tenant switching**: Stores previous tenant, switches, yields to block, ensures rollback in ensure clause with fallback to default. See `AbstractAdapter#switch` method. + +**Schema import**: Loads `db/schema.rb` or custom schema file. See schema import logic in `abstract_adapter.rb`. + +### Helper Methods + +**Environmentify**: Adds Rails environment prefix/suffix to tenant name based on configuration. See `AbstractAdapter#environmentify` method. + +**Excluded model processing**: Establishes separate connections for excluded models. See `AbstractAdapter#process_excluded_models` method. + +## PostgreSQL Adapter + +**Location**: `postgresql_adapter.rb` + +### Strategy + +Uses **PostgreSQL schemas** (namespaces) for tenant isolation. + +### Key Implementation Details + +**Create tenant**: Executes `CREATE SCHEMA` SQL command. See `PostgresqlAdapter#create_tenant` method. + +**Switch tenant**: Changes `search_path` to target schema. See `PostgresqlAdapter#connect_to_new` method. + +**Drop tenant**: Executes `DROP SCHEMA CASCADE`. See `PostgresqlAdapter#drop_command` method. + +**Get current tenant**: Returns instance variable tracking current schema. See `PostgresqlAdapter#current` method. + +### Search Path Mechanics + +PostgreSQL searches schemas in order defined by `search_path`. Queries resolve to first matching table. Search path includes tenant schema, persistent schemas, then public. See search path construction in `PostgresqlAdapter#connect_to_new`. + +### Persistent Schemas + +Configured via `config.persistent_schemas` to specify schemas that remain in search path across all tenants. + +**Use cases**: +- Shared PostgreSQL extensions (uuid-ossp, hstore, postgis) +- Utility functions/views shared across tenants +- Reference data tables + +**See**: README.md for configuration examples. + +### Excluded Names (pg_excluded_names) + +Configured via `config.pg_excluded_names` to exclude tables/schemas from tenant cloning. + +**Use cases**: +- Temporary tables +- Backup tables +- Staging/import tables + +**See**: README.md for configuration patterns. + +### Performance Characteristics + +- **Switching**: <1ms (SQL command) +- **Memory**: ~50MB total (shared connection pool) +- **Scalability**: 100+ tenants easily +- **Isolation**: Schema-level (good, not absolute) + +## PostGIS Adapter + +**Location**: `postgis_adapter.rb` + +### Strategy + +Extends `PostgresqlAdapter` with PostGIS spatial extension support. + +### Key Differences + +**Tenant creation**: Extends base PostgresqlAdapter to automatically enable PostGIS extensions in new schemas. See `PostgisAdapter#create_tenant` method. + +**Schema dumping**: Custom logic to handle spatial types and indexes correctly. See `active_record/postgres/schema_dumper.rb`. + +### Configuration + +Typically includes PostGIS-related schemas in `persistent_schemas`. See README.md for configuration. + +## MySQL Adapters + +**Locations**: `mysql2_adapter.rb`, `trilogy_adapter.rb` + +### Strategy + +Uses **separate databases** for each tenant. + +### Key Implementation Details + +**Create tenant**: Executes `CREATE DATABASE` SQL command. See `Mysql2Adapter#create_tenant` method. + +**Switch tenant**: Establishes new connection with different database name. See `Mysql2Adapter#connect_to_new` method. + +**Drop tenant**: Executes `DROP DATABASE`. See `Mysql2Adapter#drop_command` method. + +**Get current database**: Queries current database name from connection. See `Mysql2Adapter#current` method. + +### Connection Management + +Each tenant switch establishes new connection to different database. This creates connection pool overhead compared to PostgreSQL schemas. See `Mysql2Adapter#connect_to_new` for connection establishment. + +### Multi-Server Support + +MySQL adapters support hash-based configuration mapping tenant names to full connection configs, enabling different tenants on different servers. See README.md for configuration examples. + +### Performance Characteristics + +- **Switching**: 10-50ms (connection establishment) +- **Memory**: ~20MB per active tenant (connection pool) +- **Scalability**: 10-50 concurrent tenants +- **Isolation**: Database-level (excellent) + +### Trilogy Adapter + +**Location**: `trilogy_adapter.rb` + +Identical to `Mysql2Adapter` but uses the `trilogy` gem (modern MySQL client). + +**Usage**: Auto-selected if `adapter: trilogy` in `database.yml`. + +## SQLite Adapter + +**Location**: `sqlite3_adapter.rb` + +### Strategy + +Uses **separate database files** for each tenant. + +### Key Implementation Details + +**Create tenant**: Creates new SQLite file and establishes connection. See `Sqlite3Adapter#create_tenant` method. + +**Switch tenant**: Establishes connection to different database file. See `Sqlite3Adapter#connect_to_new` method. + +**Drop tenant**: Deletes database file. See `Sqlite3Adapter#drop_command` method. + +**Database file path**: Constructs file path in db/ directory. See file path construction in `Sqlite3Adapter`. + +### Use Cases + +- ✅ **Testing**: Each test tenant is isolated file +- ✅ **Development**: Easy to inspect individual tenant data +- ✅ **Single-user apps**: Desktop or embedded applications +- ❌ **Production**: Not suitable for concurrent multi-user access + +### Performance Characteristics + +- **Switching**: 5-20ms (file I/O + connection) +- **Memory**: ~5MB per database file +- **Scalability**: Not recommended for production multi-tenant +- **Isolation**: Complete (separate files) + +## JDBC Adapters (JRuby) + +**Locations**: `abstract_jdbc_adapter.rb`, `jdbc_postgresql_adapter.rb`, `jdbc_mysql_adapter.rb` + +### Purpose + +Support JRuby deployments using JDBC drivers. + +### Implementation + +Inherit from standard adapters but use JDBC-specific connection handling. See `jdbc_postgresql_adapter.rb` and `jdbc_mysql_adapter.rb`. + +### Auto-Detection + +JRuby detection happens in `tenant.rb` - automatically selects JDBC adapters when running on JRuby. See adapter factory logic in `Apartment::Tenant.adapter_method`. + +## Adapter Selection Matrix + +| Adapter | Database Type | Strategy | Speed | Scalability | Isolation | Best For | +|------------------------|---------------|--------------|--------------|-------------|-----------|-------------------------| +| PostgresqlAdapter | PostgreSQL | Schemas | Very Fast | Excellent | Good | 100+ tenants | +| PostgisAdapter | PostGIS | Schemas | Very Fast | Excellent | Good | Spatial data apps | +| Mysql2Adapter | MySQL | Databases | Moderate | Good | Excellent | Complete isolation | +| TrilogyAdapter | MySQL | Databases | Moderate | Good | Excellent | Modern MySQL client | +| Sqlite3Adapter | SQLite | Files | Moderate | Poor | Excellent | Testing, development | +| JdbcPostgresqlAdapter | PostgreSQL | Schemas | Very Fast | Excellent | Good | JRuby deployments | +| JdbcMysqlAdapter | MySQL | Databases | Moderate | Good | Excellent | JRuby deployments | + +## Creating Custom Adapters + +To support new databases: subclass `AbstractAdapter`, implement required methods (`create_tenant`, `connect_to_new`, `drop_command`, `current`), register factory method in `tenant.rb`, and configure in `database.yml`. + +**See**: Existing adapters for patterns (`postgresql_adapter.rb` is most complex, `sqlite3_adapter.rb` is simplest), and `docs/adapters.md` for design rationale. + +## Testing Adapters + +### Adapter-Specific Tests + +Each adapter has comprehensive specs covering tenant creation, switching, deletion, error handling, and callbacks. See `spec/adapters/` for test patterns. + +## Debugging Adapters + +### Check Current Adapter + +Use `Apartment::Tenant.adapter.class.name` to inspect adapter type. + +### Inspect Configuration + +Access `adapter.instance_variable_get(:@config)` for configuration and `adapter.default_tenant` for default. + +### Database-Specific Debugging + +**PostgreSQL**: Execute `SHOW search_path` to verify current schema search path. + +**MySQL**: Execute `SELECT DATABASE()` to verify current database name. + +## Common Issues + +### Issue: Schema/Database Not Created + +**Cause**: Permissions, invalid names, or database errors + +**Debug**: Wrap `Apartment::Tenant.create` in rescue block and inspect exception class and message. + +### Issue: Switching Fails + +**Cause**: Tenant doesn't exist or connection issues + +**Debug**: Verify tenant in `Apartment.tenant_names` and check `adapter.current` state. + +### Issue: Wrong Data After Switch + +**Cause**: Improper cleanup or middleware ordering + +**Solution**: Always use block-based switching, verify middleware order. + +## Performance Optimization + +### PostgreSQL: Connection Pooling + +PostgreSQL adapters use shared connection pool across all tenants. Configure pool size in `database.yml`. See Rails connection pooling guides. + +### MySQL: Connection Pool Caching + +Consider implementing LRU cache for connection pools to limit memory usage with many tenants. Not implemented in v3 but possible via custom adapter. + +## References + +- PostgreSQL schemas: https://www.postgresql.org/docs/current/ddl-schemas.html +- MySQL databases: https://dev.mysql.com/doc/refman/8.0/en/creating-database.html +- ActiveRecord adapters: Rails source code +- AbstractAdapter source: `abstract_adapter.rb` diff --git a/lib/apartment/adapters/abstract_adapter.rb b/lib/apartment/adapters/abstract_adapter.rb index b3cc21d4..bca101cf 100644 --- a/lib/apartment/adapters/abstract_adapter.rb +++ b/lib/apartment/adapters/abstract_adapter.rb @@ -5,6 +5,7 @@ module Adapters # Abstract adapter from which all the Apartment DB related adapters will inherit the base logic class AbstractAdapter include ActiveSupport::Callbacks + define_callbacks :create, :switch attr_writer :default_tenant @@ -21,7 +22,7 @@ def initialize(config) # @param {String} tenant Tenant name # def create(tenant) - run_callbacks :create do + run_callbacks(:create) do create_tenant(tenant) switch(tenant) do @@ -72,7 +73,7 @@ def drop(tenant) # @param {String} tenant name # def switch!(tenant = nil) - run_callbacks :switch do + run_callbacks(:switch) do connect_to_new(tenant).tap do Apartment.connection.clear_query_cache end @@ -88,9 +89,11 @@ def switch(tenant = nil) switch!(tenant) yield ensure + # Always attempt rollback to previous tenant, even if block raised begin switch!(previous_tenant) rescue StandardError => _e + # If rollback fails (tenant was dropped, connection lost), fall back to default reset end end @@ -99,7 +102,7 @@ def switch(tenant = nil) # def each(tenants = Apartment.tenant_names) tenants.each do |tenant| - switch(tenant) { yield tenant } + switch(tenant) { yield(tenant) } end end @@ -116,7 +119,7 @@ def process_excluded_models # Reset the tenant connection to the default # def reset - Apartment.establish_connection @config + Apartment.establish_connection(@config) end # Load the rails seed file into the db @@ -147,7 +150,7 @@ def environmentify(tenant) protected def process_excluded_model(excluded_model) - excluded_model.constantize.establish_connection @config + excluded_model.constantize.establish_connection(@config) end def drop_command(conn, tenant) @@ -178,11 +181,14 @@ def create_tenant_command(conn, tenant) def connect_to_new(tenant) return reset if tenant.nil? + # Preserve query cache state across tenant switches + # Rails disables it during connection establishment query_cache_enabled = ActiveRecord::Base.connection.query_cache_enabled - Apartment.establish_connection multi_tenantify(tenant) - Apartment.connection.verify! # call active? to manually check if this connection is valid + Apartment.establish_connection(multi_tenantify(tenant)) + Apartment.connection.verify! # Explicitly validate connection is live + # Restore query cache if it was previously enabled Apartment.connection.enable_query_cache! if query_cache_enabled rescue *rescuable_exceptions => e Apartment::Tenant.reset if reset_on_connection_exception? @@ -216,7 +222,7 @@ def multi_tenantify_with_tenant_db_name(config, tenant) # Load a file or raise error if it doesn't exists # def load_or_raise(file) - raise FileNotFound, "#{file} doesn't exist yet" unless File.exist?(file) + raise(FileNotFound, "#{file} doesn't exist yet") unless File.exist?(file) load(file) end @@ -239,15 +245,16 @@ def db_connection_config(tenant) Apartment.db_config_for(tenant).dup end - def with_neutral_connection(tenant, &_block) + def with_neutral_connection(tenant, &) if Apartment.with_multi_server_setup - # neutral connection is necessary whenever you need to create/remove a database from a server. - # example: when you use postgresql, you need to connect to the default postgresql database before you create - # your own. + # Multi-server setup requires separate connection handler to avoid polluting + # the main connection pool. For example: connecting to postgres 'template1' + # database to CREATE/DROP tenant databases without affecting app connections. SeparateDbConnectionHandler.establish_connection(multi_tenantify(tenant, false)) yield(SeparateDbConnectionHandler.connection) SeparateDbConnectionHandler.connection.close else + # Single-server: reuse existing connection (safe for most operations) yield(Apartment.connection) end end @@ -257,17 +264,19 @@ def reset_on_connection_exception? end def raise_drop_tenant_error!(tenant, exception) - raise TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{exception.message}" + raise(TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{exception.message}") end def raise_create_tenant_error!(tenant, exception) - raise TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{exception.message}" + raise(TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{exception.message}") end def raise_connect_error!(tenant, exception) - raise TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{exception.message}" + raise(TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{exception.message}") end + # Dedicated AR connection class for neutral connections (admin operations like CREATE/DROP DATABASE). + # Prevents admin commands from polluting the main application connection pool. class SeparateDbConnectionHandler < ::ActiveRecord::Base end end diff --git a/lib/apartment/adapters/jdbc_mysql_adapter.rb b/lib/apartment/adapters/jdbc_mysql_adapter.rb index 90c1bfeb..99ef4ea6 100644 --- a/lib/apartment/adapters/jdbc_mysql_adapter.rb +++ b/lib/apartment/adapters/jdbc_mysql_adapter.rb @@ -5,7 +5,7 @@ module Apartment module Tenant def self.jdbc_mysql_adapter(config) - Adapters::JDBCMysqlAdapter.new config + Adapters::JDBCMysqlAdapter.new(config) end end diff --git a/lib/apartment/adapters/jdbc_postgresql_adapter.rb b/lib/apartment/adapters/jdbc_postgresql_adapter.rb index 3e9494b0..d62e81a4 100644 --- a/lib/apartment/adapters/jdbc_postgresql_adapter.rb +++ b/lib/apartment/adapters/jdbc_postgresql_adapter.rb @@ -38,12 +38,12 @@ class JDBCPostgresqlSchemaAdapter < PostgresqlSchemaAdapter # def connect_to_new(tenant = nil) return reset if tenant.nil? - raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless schema_exists?(tenant) + raise(ActiveRecord::StatementInvalid, "Could not find schema #{tenant}") unless schema_exists?(tenant) @current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s Apartment.connection.schema_search_path = full_search_path rescue ActiveRecord::StatementInvalid, ActiveRecord::JDBCError - raise TenantNotFound, "One of the following schema(s) is invalid: #{full_search_path}" + raise(TenantNotFound, "One of the following schema(s) is invalid: #{full_search_path}") end private @@ -51,7 +51,7 @@ def connect_to_new(tenant = nil) def tenant_exists?(tenant) return true unless Apartment.tenant_presence_check - Apartment.connection.all_schemas.include? tenant + Apartment.connection.all_schemas.include?(tenant) end def rescue_from diff --git a/lib/apartment/adapters/mysql2_adapter.rb b/lib/apartment/adapters/mysql2_adapter.rb index ca9b1a7f..e030fde1 100644 --- a/lib/apartment/adapters/mysql2_adapter.rb +++ b/lib/apartment/adapters/mysql2_adapter.rb @@ -44,7 +44,7 @@ def initialize(config) def reset return unless default_tenant - Apartment.connection.execute "use `#{default_tenant}`" + Apartment.connection.execute("use `#{default_tenant}`") end protected @@ -54,7 +54,7 @@ def reset def connect_to_new(tenant) return reset if tenant.nil? - Apartment.connection.execute "use `#{environmentify(tenant)}`" + Apartment.connection.execute("use `#{environmentify(tenant)}`") rescue ActiveRecord::StatementInvalid => e Apartment::Tenant.reset raise_connect_error!(tenant, e) diff --git a/lib/apartment/adapters/postgresql_adapter.rb b/lib/apartment/adapters/postgresql_adapter.rb index 1c41b49f..45f32a19 100644 --- a/lib/apartment/adapters/postgresql_adapter.rb +++ b/lib/apartment/adapters/postgresql_adapter.rb @@ -57,7 +57,8 @@ def current def process_excluded_model(excluded_model) excluded_model.constantize.tap do |klass| - # Ensure that if a schema *was* set, we override + # Strip any existing schema qualifier (handles "schema.table" → "table") + # Then explicitly set to default schema to prevent tenant-based queries table_name = klass.table_name.split('.', 2).last klass.table_name = "#{default_tenant}.#{table_name}" @@ -72,7 +73,7 @@ def drop_command(conn, tenant) # def connect_to_new(tenant = nil) return reset if tenant.nil? - raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless schema_exists?(tenant) + raise(ActiveRecord::StatementInvalid, "Could not find schema #{tenant}") unless schema_exists?(tenant) @current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s Apartment.connection.schema_search_path = full_search_path @@ -89,7 +90,8 @@ def tenant_exists?(tenant) end def create_tenant_command(conn, tenant) - # NOTE: This was causing some tests to fail because of the database strategy for rspec + # Avoid nested transactions: if already in transaction (e.g., RSpec tests), + # execute directly. Otherwise, wrap in explicit transaction for atomicity. if ActiveRecord::Base.connection.open_transactions.positive? conn.execute(%(CREATE SCHEMA "#{tenant}")) else @@ -101,7 +103,7 @@ def create_tenant_command(conn, tenant) end rescue *rescuable_exceptions => e rollback_transaction(conn) - raise e + raise(e) end def rollback_transaction(conn) @@ -131,7 +133,7 @@ def schema_exists?(schemas) end def raise_schema_connect_to_new(tenant, exception) - raise TenantNotFound, <<~EXCEPTION_MESSAGE + raise(TenantNotFound, <<~EXCEPTION_MESSAGE) Could not set search path to schemas, they may be invalid: "#{tenant}" #{full_search_path}. Original error: #{exception.class}: #{exception} EXCEPTION_MESSAGE @@ -152,6 +154,26 @@ class PostgresqlSchemaFromSqlAdapter < PostgresqlSchemaAdapter ].freeze + # PostgreSQL meta-commands (backslash commands) that appear in pg_dump output + # but are not valid SQL when passed to ActiveRecord's execute(). + # These must be filtered out to prevent syntax errors during schema import. + PSQL_META_COMMANDS = [ + /^\\connect/i, + /^\\set/i, + /^\\unset/i, + /^\\copyright/i, + /^\\echo/i, + /^\\warn/i, + /^\\o/i, + /^\\t/i, + /^\\q/i, + /^\\./i, # Catch-all for any backslash command (e.g., \. for COPY delimiter, + # \restrict/\unrestrict in PostgreSQL 17.6+, and future meta-commands) + ].freeze + + # Combined blacklist: SQL statements and psql meta-commands to filter from pg_dump output + PSQL_DUMP_GLOBAL_BLACKLIST = (PSQL_DUMP_BLACKLISTED_STATEMENTS + PSQL_META_COMMANDS).freeze + def import_database_schema preserving_search_path do clone_pg_schema @@ -161,9 +183,9 @@ def import_database_schema private - # Re-set search path after the schema is imported. - # Postgres now sets search path to empty before dumping the schema - # and it mut be reset + # PostgreSQL's pg_dump clears search_path in the dump output, which would + # leave us with an empty path after import. Capture current path, execute + # import, then restore it to maintain tenant context. # def preserving_search_path search_path = Apartment.connection.execute('show search_path').first['search_path'] @@ -209,13 +231,14 @@ def pg_dump_schema_migrations_data end # rubocop:enable Layout/LineLength - # Temporary set Postgresql related environment variables if there are in @config - # + # Temporarily set PostgreSQL environment variables for pg_dump shell commands. + # Must preserve and restore existing ENV values to avoid polluting global state. + # pg_dump reads these instead of passing connection params as CLI args. def with_pg_env - pghost = ENV['PGHOST'] - pgport = ENV['PGPORT'] - pguser = ENV['PGUSER'] - pgpassword = ENV['PGPASSWORD'] + pghost = ENV.fetch('PGHOST', nil) + pgport = ENV.fetch('PGPORT', nil) + pguser = ENV.fetch('PGUSER', nil) + pgpassword = ENV.fetch('PGPASSWORD', nil) ENV['PGHOST'] = @config[:host] if @config[:host] ENV['PGPORT'] = @config[:port].to_s if @config[:port] @@ -224,6 +247,7 @@ def with_pg_env yield ensure + # Always restore original ENV state (might be nil) ENV['PGHOST'] = pghost ENV['PGPORT'] = pgport ENV['PGUSER'] = pguser @@ -239,16 +263,15 @@ def patch_search_path(sql) swap_schema_qualifier(sql) .split("\n") - .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? } + .grep_v(Regexp.union(PSQL_DUMP_GLOBAL_BLACKLIST)) .prepend(search_path) .join("\n") end def swap_schema_qualifier(sql) sql.gsub(/#{default_tenant}\.\w*/) do |match| - if Apartment.pg_excluded_names.any? { |name| match.include? name } - match - elsif Apartment.pg_exclude_clone_tables && excluded_tables.any?(match) + if Apartment.pg_excluded_names.any? { |name| match.include?(name) } || + (Apartment.pg_exclude_clone_tables && excluded_tables.any?(match)) match else match.gsub("#{default_tenant}.", %("#{current}".)) @@ -259,7 +282,7 @@ def swap_schema_qualifier(sql) # Checks if any of regexps matches against input # def check_input_against_regexps(input, regexps) - regexps.select { |c| input.match c } + regexps.select { |c| input.match(c) } end # Convenience method for excluded table names diff --git a/lib/apartment/adapters/sqlite3_adapter.rb b/lib/apartment/adapters/sqlite3_adapter.rb index bfa6f3de..f93239fb 100644 --- a/lib/apartment/adapters/sqlite3_adapter.rb +++ b/lib/apartment/adapters/sqlite3_adapter.rb @@ -19,8 +19,8 @@ def initialize(config) def drop(tenant) unless File.exist?(database_file(tenant)) - raise TenantNotFound, - "The tenant #{environmentify(tenant)} cannot be found." + raise(TenantNotFound, + "The tenant #{environmentify(tenant)} cannot be found.") end File.delete(database_file(tenant)) @@ -36,17 +36,17 @@ def connect_to_new(tenant) return reset if tenant.nil? unless File.exist?(database_file(tenant)) - raise TenantNotFound, - "The tenant #{environmentify(tenant)} cannot be found." + raise(TenantNotFound, + "The tenant #{environmentify(tenant)} cannot be found.") end - super database_file(tenant) + super(database_file(tenant)) end def create_tenant(tenant) if File.exist?(database_file(tenant)) - raise TenantExists, - "The tenant #{environmentify(tenant)} already exists." + raise(TenantExists, + "The tenant #{environmentify(tenant)} already exists.") end begin diff --git a/lib/apartment/console.rb b/lib/apartment/console.rb index 6cc3900d..e732f03d 100644 --- a/lib/apartment/console.rb +++ b/lib/apartment/console.rb @@ -4,7 +4,7 @@ def st(schema_name = nil) if schema_name.nil? tenant_list.each { |t| puts t } - elsif tenant_list.include? schema_name + elsif tenant_list.include?(schema_name) Apartment::Tenant.switch!(schema_name) else puts "Tenant #{schema_name} is not part of the tenant list" diff --git a/lib/apartment/custom_console.rb b/lib/apartment/custom_console.rb index 7b32a5b5..23e1bae7 100644 --- a/lib/apartment/custom_console.rb +++ b/lib/apartment/custom_console.rb @@ -5,7 +5,7 @@ module Apartment module CustomConsole begin - require 'pry-rails' + require('pry-rails') rescue LoadError # rubocop:disable Layout/LineLength puts '[Failed to load pry-rails] If you want to use Apartment custom prompt you need to add pry-rails to your gemfile' @@ -13,17 +13,17 @@ module CustomConsole end desc = "Includes the current Rails environment and project folder name.\n" \ - '[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>' + '[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>' prompt_procs = [ proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '>') }, - proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '*') } + proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '*') }, ] if Gem::Version.new(Pry::VERSION) >= Gem::Version.new('0.13') - Pry.config.prompt = Pry::Prompt.new 'ros', desc, prompt_procs + Pry.config.prompt = Pry::Prompt.new('ros', desc, prompt_procs) else - Pry::Prompt.add 'ros', desc, %w[> *] do |target_self, nest_level, pry, sep| + Pry::Prompt.add('ros', desc, %w[> *]) do |target_self, nest_level, pry, sep| prompt_contents(pry, target_self, nest_level, sep) end Pry.config.prompt = Pry::Prompt[:ros][:value] @@ -35,8 +35,8 @@ module CustomConsole def self.prompt_contents(pry, target_self, nest_level, sep) "[#{pry.input_ring.size}] [#{PryRails::Prompt.formatted_env}][#{Apartment::Tenant.current}] " \ - "#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \ - "#{":#{nest_level}" unless nest_level.zero?}#{sep} " + "#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \ + "#{":#{nest_level}" unless nest_level.zero?}#{sep} " end end end diff --git a/lib/apartment/elevators/CLAUDE.md b/lib/apartment/elevators/CLAUDE.md new file mode 100644 index 00000000..321ece3c --- /dev/null +++ b/lib/apartment/elevators/CLAUDE.md @@ -0,0 +1,292 @@ +# lib/apartment/elevators/ - Rack Middleware for Tenant Switching + +This directory contains Rack middleware components ("elevators") that automatically detect and switch to the appropriate tenant based on incoming HTTP requests. + +## Purpose + +Elevators intercept incoming requests and establish tenant context **before** the application processes the request. This eliminates the need for manual tenant switching in controllers. + +## Metaphor + +Like a physical elevator taking you to different floors, these middleware components "elevate" your request to the correct tenant context. + +## File Structure + +``` +elevators/ +├── generic.rb # Base elevator with customizable logic +├── subdomain.rb # Switch based on subdomain (e.g., acme.example.com) +├── first_subdomain.rb # Switch based on first subdomain in chain +├── domain.rb # Switch based on domain (excluding www and TLD) +├── host.rb # Switch based on full hostname +└── host_hash.rb # Switch based on hostname → tenant hash mapping +``` + +## How Elevators Work + +### Rack Middleware Pattern + +All elevators are Rack middleware that intercept requests, extract tenant identifier, switch context, invoke next middleware, and ensure cleanup. See `generic.rb` for base implementation. + +### Request Lifecycle with Elevator + +HTTP Request → Elevator extracts tenant → Switch to tenant → Application processes → Automatic cleanup (ensure block) → HTTP Response + +**See**: `Generic#call` method for middleware call pattern. + +## Generic Elevator - Base Class + +**Location**: `generic.rb` + +### Purpose + +Provides base implementation and allows custom tenant resolution via Proc or subclass. + +### Implementation + +Accepts optional Proc in initializer or expects `parse_tenant_name(request)` override in subclass. See `Generic` class implementation in `generic.rb`. + +### Usage Patterns + +**With Proc**: Pass Proc to Generic that extracts tenant from Rack::Request. + +**Via Subclass**: Inherit from Generic and override `parse_tenant_name`. + +**See**: `generic.rb` and README.md for usage examples. + +## Subdomain Elevator + +**Location**: `subdomain.rb` + +### Strategy + +Extract first subdomain from hostname. + +### Implementation + +Uses `request.subdomain` and checks against `excluded_subdomains` class attribute. Returns nil for excluded subdomains. See `Subdomain#parse_tenant_name` in `subdomain.rb`. + +### Configuration + +Add to middleware stack in `application.rb` and configure `excluded_subdomains` class attribute. See README.md for examples. + +### Behavior + +| Request URL | Subdomain | Excluded? | Tenant | +|------------------------------|-----------|-----------|-------------| +| http://acme.example.com | acme | No | acme | +| http://widgets.example.com | widgets | No | widgets | +| http://www.example.com | www | Yes | (default) | +| http://api.example.com | api | Yes | (default) | +| http://example.com | (empty) | N/A | (default) | + +### Why PublicSuffix Dependency? + +**Rationale**: International domains require proper TLD parsing. Without PublicSuffix, `example.co.uk` would incorrectly parse `.uk` as the TLD rather than `.co.uk`, causing subdomain extraction to fail. + +**Trade-off**: Adds gem dependency, but necessary for international domain support. + +## FirstSubdomain Elevator + +**Location**: `first_subdomain.rb` + +### Strategy + +Extract **first** subdomain from chain (for nested subdomains). + +### Implementation + +Splits subdomain on `.` and takes first part. See `FirstSubdomain#parse_tenant_name` in `first_subdomain.rb`. + +### Configuration + +Add to middleware stack and configure excluded subdomains. See README.md for configuration. + +### Use Case + +Multi-level subdomain structures where tenant is always leftmost: +- `{tenant}.api.example.com` +- `{tenant}.app.example.com` +- `{tenant}.staging.example.com` + +### Note + +In current v3 implementation, `Subdomain` and `FirstSubdomain` may behave identically depending on Rails version due to how `request.subdomain` works. For true nested support, test thoroughly or use custom elevator. + +## Domain Elevator + +**Location**: `domain.rb` + +### Strategy + +Use domain name (excluding 'www' and top-level domain) as tenant. + +### Implementation + +Extracts domain name excluding TLD and 'www' prefix. See `Domain#parse_tenant_name` in `domain.rb`. + +### Configuration + +Add to middleware stack. See README.md. + +### Use Case + +When full domain (not subdomain) identifies tenant: +- `acme-corp.com` → tenant: acme-corp +- `widgets-inc.com` → tenant: widgets-inc + +## Host Elevator + +**Location**: `host.rb` + +### Strategy + +Use **full hostname** as tenant, optionally ignoring specified first subdomains. + +### Implementation + +Uses full hostname as tenant, optionally ignoring specified first subdomains. See `Host#parse_tenant_name` in `host.rb`. + +### Configuration + +Add to middleware stack and configure `ignored_first_subdomains`. See README.md. + +### Use Case + +When each full hostname represents a different tenant: +- Tenants use custom domains: `acme-corp.com`, `widgets-inc.net` +- Internal apps: `billing.internal.company.com`, `crm.internal.company.com` + +## HostHash Elevator + +**Location**: `host_hash.rb` + +### Strategy + +Direct **mapping** from hostname to tenant name via hash. + +### Implementation + +Accepts hash mapping hostnames to tenant names. See `HostHash` implementation in `host_hash.rb`. + +### Configuration + +Pass hash to HostHash initializer when adding to middleware stack. See README.md for examples. + +### Use Cases + +- **Custom domains**: Each tenant has their own domain +- **Explicit mapping**: No parsing logic, direct control +- **Different TLDs**: .com, .io, .net, etc. + +### Advantages + +- ✅ Explicit control +- ✅ No parsing ambiguity +- ✅ Works with any hostname pattern + +### Disadvantages + +- ❌ Requires manual configuration per tenant +- ❌ Not dynamic (requires app restart for changes) +- ❌ Doesn't scale to hundreds of tenants + +## Middleware Positioning + +### Why Position Matters + +**Critical constraint**: Elevators must run before session and authentication middleware. + +**Why this matters**: Session middleware loads user data based on session ID. If session loads before tenant is established, you get the wrong tenant's session data. This creates security vulnerabilities where User A sees User B's data. + +**Example failure**: Without proper positioning, `www.acme.com` might load session data from `widgets.com` tenant if session middleware runs first. + +**How to verify**: Run `Rails.application.middleware` and confirm elevator appears before `ActionDispatch::Session::CookieStore` and authentication middleware like `Warden::Manager`. + +## Creating Custom Elevators + +### Method 1: Using Proc with Generic + +Pass Proc to Generic elevator for inline tenant detection logic. See `generic.rb` and README.md. + +### Method 2: Subclassing Generic + +Create custom class inheriting from Generic, override `parse_tenant_name(request)`. Supports multi-strategy fallback logic. See `generic.rb` for base class. + +## Error Handling + +### Handling Missing Tenants + +Custom elevators can rescue `Apartment::TenantNotFound` and return appropriate HTTP responses (404, redirect, etc.). See `generic.rb` for base call pattern. + +### Custom Error Pages + +Override `call(env)` method to wrap `super` in rescue block and handle errors. See existing elevator implementations for patterns. + +## Testing Elevators + +### Unit Testing + +Use `Rack::MockRequest` to create test requests and mock `Apartment::Tenant.switch`. See `spec/unit/elevators/` for test patterns. + +### Integration Testing + +Create test tenants in before hooks, make requests to different subdomains/hosts, verify correct tenant context. See `spec/integration/` for examples. + +## Performance Considerations + +### Why Caching Matters for Custom Elevators + +**Problem**: If your custom elevator queries the database to resolve tenant (e.g., looking up tenant by API key), you add database latency to **every request**. + +**Impact**: 10-50ms per request × thousands of requests = significant overhead. + +**Solution**: Cache tenant name lookups. Trade-off is stale cache if tenants are renamed, but this is rare. + +### Why Preloaded Hash Maps Beat Database Queries + +**Database query approach**: SELECT tenant_name FROM tenants WHERE subdomain = ? — runs on every request. + +**Hash map approach**: Loaded once at boot, O(1) lookup with no I/O. + +**Trade-off**: Hash maps don't update without restart, but for most applications tenant-to-subdomain mapping is stable. + +### Why Monitor Elevator Performance + +**Hidden cost**: Elevator runs on every request. 10ms overhead is 10% latency penalty on a 100ms request. + +**Target**: Elevator should complete in <5ms. If >100ms, investigate and add logging. + +## Common Issues + +### Issue: Elevator Not Triggering + +**Symptoms**: Tenant always default + +**Causes**: Elevator not in middleware stack, `parse_tenant_name` returning nil, or incorrect middleware positioning + +**Debug**: Add logging to `parse_tenant_name` to inspect extracted tenant values. + +### Issue: TenantNotFound Errors + +**Symptoms**: 500 errors on some requests + +**Causes**: Tenant doesn't exist or subdomain not in tenant list + +**Solution**: Add error handling in custom elevator or validate tenant existence before switching. + +## Best Practices + +1. **Position elevators early** in middleware stack +2. **Handle errors gracefully** (don't expose internals) +3. **Cache lookups** if using database queries +4. **Test thoroughly** with multiple tenants +5. **Monitor performance** (log slow switches) +6. **Document custom logic** for maintainability + +## References + +- Rack middleware: https://github.com/rack/rack/wiki/Middleware +- Rack::Request: https://www.rubydoc.info/github/rack/rack/Rack/Request +- Rails middleware: https://guides.rubyonrails.org/rails_on_rack.html +- Generic elevator: `generic.rb` diff --git a/lib/apartment/elevators/domain.rb b/lib/apartment/elevators/domain.rb index 8915289a..d134aa2f 100644 --- a/lib/apartment/elevators/domain.rb +++ b/lib/apartment/elevators/domain.rb @@ -16,7 +16,7 @@ class Domain < Generic def parse_tenant_name(request) return nil if request.host.blank? - request.host.match(/(www\.)?(?[^.]*)/)['sld'] + request.host.match(/(?:www\.)?(?[^.]*)/)['sld'] end end end diff --git a/lib/apartment/elevators/generic.rb b/lib/apartment/elevators/generic.rb index a765486e..b52d349e 100644 --- a/lib/apartment/elevators/generic.rb +++ b/lib/apartment/elevators/generic.rb @@ -26,7 +26,7 @@ def call(env) end def parse_tenant_name(_request) - raise 'Override' + raise('Override') end end end diff --git a/lib/apartment/elevators/host_hash.rb b/lib/apartment/elevators/host_hash.rb index f68c10cb..cce3ce93 100644 --- a/lib/apartment/elevators/host_hash.rb +++ b/lib/apartment/elevators/host_hash.rb @@ -9,14 +9,14 @@ module Elevators # class HostHash < Generic def initialize(app, hash = {}, processor = nil) - super app, processor + super(app, processor) @hash = hash end def parse_tenant_name(request) unless @hash.key?(request.host) - raise TenantNotFound, - "Cannot find tenant for host #{request.host}" + raise(TenantNotFound, + "Cannot find tenant for host #{request.host}") end @hash[request.host] diff --git a/lib/apartment/elevators/subdomain.rb b/lib/apartment/elevators/subdomain.rb index 38604d60..22641218 100644 --- a/lib/apartment/elevators/subdomain.rb +++ b/lib/apartment/elevators/subdomain.rb @@ -22,8 +22,9 @@ def self.excluded_subdomains=(arg) def parse_tenant_name(request) request_subdomain = subdomain(request.host) - # If the domain acquired is set to be excluded, set the tenant to whatever is currently - # next in line in the schema search path. + # Excluded subdomains (www, api, admin) return nil → uses default tenant. + # Returning nil instead of default_tenant name allows Apartment to decide + # the fallback behavior. tenant = if self.class.excluded_subdomains.include?(request_subdomain) nil else @@ -35,11 +36,11 @@ def parse_tenant_name(request) protected - # *Almost* a direct ripoff of ActionDispatch::Request subdomain methods + # Subdomain extraction using PublicSuffix to handle international TLDs correctly. + # Examples: api.example.com → "api", www.example.co.uk → "www" - # Only care about the first subdomain for the database name def subdomain(host) - subdomains(host).first + subdomains(host).first # Only first subdomain matters for tenant resolution end def subdomains(host) @@ -50,6 +51,7 @@ def host_valid?(host) !ip_host?(host) && domain_valid?(host) end + # Reject IP addresses (127.0.0.1, 192.168.1.1) - no subdomain concept def ip_host?(host) !/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil? end @@ -58,6 +60,8 @@ def domain_valid?(host) PublicSuffix.valid?(host, ignore_private: true) end + # PublicSuffix.parse handles TLDs correctly: example.co.uk has TLD "co.uk" + # .trd (third-level domain) returns subdomain parts, excluding TLD def parse_host(host) (PublicSuffix.parse(host, ignore_private: true).trd || '').split('.') end diff --git a/lib/apartment/log_subscriber.rb b/lib/apartment/log_subscriber.rb index 2271d42f..f5194bec 100644 --- a/lib/apartment/log_subscriber.rb +++ b/lib/apartment/log_subscriber.rb @@ -14,7 +14,7 @@ def sql(event) private - def debug(progname = nil, &blk) + def debug(progname = nil, &) progname = " #{apartment_log}#{progname}" unless progname.nil? super diff --git a/lib/apartment/migrator.rb b/lib/apartment/migrator.rb index a8c5f435..b66c1378 100644 --- a/lib/apartment/migrator.rb +++ b/lib/apartment/migrator.rb @@ -4,12 +4,12 @@ module Apartment module Migrator - extend self + module_function # Migrate to latest def migrate(database) Tenant.switch(database) do - version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil + version = ENV['VERSION']&.to_i migration_scope_block = ->(migration) { ENV['SCOPE'].blank? || (ENV['SCOPE'] == migration.scope) } diff --git a/lib/apartment/model.rb b/lib/apartment/model.rb index 8401cd64..fccc5da5 100644 --- a/lib/apartment/model.rb +++ b/lib/apartment/model.rb @@ -14,7 +14,7 @@ def cached_find_by_statement(key, &block) # Modifying the cache key to have a reference to the current tenant, # so the cached statement is referring only to the tenant in which we've # executed this - cache_key = if key.is_a? String + cache_key = if key.is_a?(String) "#{Apartment::Tenant.current}_#{key}" else # NOTE: In Rails 6.0.4 we start receiving an ActiveRecord::Reflection::BelongsToReflection diff --git a/lib/apartment/railtie.rb b/lib/apartment/railtie.rb index 4ee5f746..3f20598c 100644 --- a/lib/apartment/railtie.rb +++ b/lib/apartment/railtie.rb @@ -48,12 +48,12 @@ class Railtie < Rails::Railtie # NOTE: Load the custom log subscriber if enabled if Apartment.active_record_log ActiveSupport::Notifications.notifier.listeners_for('sql.active_record').each do |listener| - next unless listener.instance_variable_get('@delegate').is_a?(ActiveRecord::LogSubscriber) + next unless listener.instance_variable_get(:@delegate).is_a?(ActiveRecord::LogSubscriber) - ActiveSupport::Notifications.unsubscribe listener + ActiveSupport::Notifications.unsubscribe(listener) end - Apartment::LogSubscriber.attach_to :active_record + Apartment::LogSubscriber.attach_to(:active_record) end end diff --git a/lib/apartment/tasks/enhancements.rb b/lib/apartment/tasks/enhancements.rb index f9a13206..71c0ac40 100644 --- a/lib/apartment/tasks/enhancements.rb +++ b/lib/apartment/tasks/enhancements.rb @@ -46,7 +46,7 @@ def enhance_after_task(task) end def inserted_task_name(task) - task.name.sub(/db:/, 'apartment:') + task.name.sub('db:', 'apartment:') end end end diff --git a/lib/apartment/tasks/task_helper.rb b/lib/apartment/tasks/task_helper.rb index 52d2fdd5..7442f7ce 100644 --- a/lib/apartment/tasks/task_helper.rb +++ b/lib/apartment/tasks/task_helper.rb @@ -2,10 +2,10 @@ module Apartment module TaskHelper - def self.each_tenant(&block) + def self.each_tenant Parallel.each(tenants_without_default, in_threads: Apartment.parallel_migration_threads) do |tenant| Rails.application.executor.wrap do - block.call(tenant) + yield(tenant) end end end @@ -44,9 +44,9 @@ def self.migrate_tenant(tenant_name) create_tenant(tenant_name) if strategy == :create_tenant puts("Migrating #{tenant_name} tenant") - Apartment::Migrator.migrate tenant_name + Apartment::Migrator.migrate(tenant_name) rescue Apartment::TenantNotFound => e - raise e if strategy == :raise_exception + raise(e) if strategy == :raise_exception puts e.message end diff --git a/lib/apartment/tenant.rb b/lib/apartment/tenant.rb index abbe87d5..a7fac36a 100644 --- a/lib/apartment/tenant.rb +++ b/lib/apartment/tenant.rb @@ -32,13 +32,13 @@ def adapter end begin - require "apartment/adapters/#{adapter_method}" + require("apartment/adapters/#{adapter_method}") rescue LoadError - raise "The adapter `#{adapter_method}` is not yet supported" + raise("The adapter `#{adapter_method}` is not yet supported") end unless respond_to?(adapter_method) - raise AdapterNotFound, "database configuration specifies nonexistent #{config[:adapter]} adapter" + raise(AdapterNotFound, "database configuration specifies nonexistent #{config[:adapter]} adapter") end send(adapter_method, config) diff --git a/lib/apartment/version.rb b/lib/apartment/version.rb index f22d226b..db92fc09 100644 --- a/lib/apartment/version.rb +++ b/lib/apartment/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Apartment - VERSION = '3.2.0' + VERSION = '3.3.0' end diff --git a/lib/generators/apartment/install/install_generator.rb b/lib/generators/apartment/install/install_generator.rb index 9fe7152e..0477a438 100755 --- a/lib/generators/apartment/install/install_generator.rb +++ b/lib/generators/apartment/install/install_generator.rb @@ -5,7 +5,7 @@ class InstallGenerator < Rails::Generators::Base source_root File.expand_path('templates', __dir__) def copy_files - template 'apartment.rb', File.join('config', 'initializers', 'apartment.rb') + template('apartment.rb', File.join('config', 'initializers', 'apartment.rb')) end end end diff --git a/lib/generators/apartment/install/templates/apartment.rb b/lib/generators/apartment/install/templates/apartment.rb index 17f73c60..3cb2be31 100644 --- a/lib/generators/apartment/install/templates/apartment.rb +++ b/lib/generators/apartment/install/templates/apartment.rb @@ -50,7 +50,7 @@ # end # end # - config.tenant_names = -> { ToDo_Tenant_Or_User_Model.pluck :database } + config.tenant_names = -> { ToDo_Tenant_Or_User_Model.pluck(:database) } # PostgreSQL: # Specifies whether to use PostgreSQL schemas or create a new database per Tenant. @@ -111,6 +111,6 @@ # } # Rails.application.config.middleware.use Apartment::Elevators::Domain -Rails.application.config.middleware.use Apartment::Elevators::Subdomain +Rails.application.config.middleware.use(Apartment::Elevators::Subdomain) # Rails.application.config.middleware.use Apartment::Elevators::FirstSubdomain # Rails.application.config.middleware.use Apartment::Elevators::Host diff --git a/lib/tasks/apartment.rake b/lib/tasks/apartment.rake index 6cb74393..eb161fff 100644 --- a/lib/tasks/apartment.rake +++ b/lib/tasks/apartment.rake @@ -4,9 +4,9 @@ require 'apartment/migrator' require 'apartment/tasks/task_helper' require 'parallel' -apartment_namespace = namespace :apartment do - desc 'Create all tenants' - task :create do +apartment_namespace = namespace(:apartment) do + desc('Create all tenants') + task(create: :environment) do Apartment::TaskHelper.warn_if_tenants_empty Apartment::TaskHelper.tenants.each do |tenant| @@ -14,8 +14,8 @@ apartment_namespace = namespace :apartment do end end - desc 'Drop all tenants' - task :drop do + desc('Drop all tenants') + task(drop: :environment) do Apartment::TaskHelper.tenants.each do |tenant| puts("Dropping #{tenant} tenant") Apartment::Tenant.drop(tenant) @@ -24,16 +24,16 @@ apartment_namespace = namespace :apartment do end end - desc 'Migrate all tenants' - task :migrate do + desc('Migrate all tenants') + task(migrate: :environment) do Apartment::TaskHelper.warn_if_tenants_empty Apartment::TaskHelper.each_tenant do |tenant| Apartment::TaskHelper.migrate_tenant(tenant) end end - desc 'Seed all tenants' - task :seed do + desc('Seed all tenants') + task(seed: :environment) do Apartment::TaskHelper.warn_if_tenants_empty Apartment::TaskHelper.each_tenant do |tenant| @@ -47,53 +47,53 @@ apartment_namespace = namespace :apartment do end end - desc 'Rolls the migration back to the previous version (specify steps w/ STEP=n) across all tenants.' - task :rollback do + desc('Rolls the migration back to the previous version (specify steps w/ STEP=n) across all tenants.') + task(rollback: :environment) do Apartment::TaskHelper.warn_if_tenants_empty step = ENV['STEP'] ? ENV['STEP'].to_i : 1 Apartment::TaskHelper.each_tenant do |tenant| puts("Rolling back #{tenant} tenant") - Apartment::Migrator.rollback tenant, step + Apartment::Migrator.rollback(tenant, step) rescue Apartment::TenantNotFound => e puts e.message end end - namespace :migrate do - desc 'Runs the "up" for a given migration VERSION across all tenants.' - task :up do + namespace(:migrate) do + desc('Runs the "up" for a given migration VERSION across all tenants.') + task(up: :environment) do Apartment::TaskHelper.warn_if_tenants_empty - version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil - raise 'VERSION is required' unless version + version = ENV['VERSION']&.to_i + raise('VERSION is required') unless version Apartment::TaskHelper.each_tenant do |tenant| puts("Migrating #{tenant} tenant up") - Apartment::Migrator.run :up, tenant, version + Apartment::Migrator.run(:up, tenant, version) rescue Apartment::TenantNotFound => e puts e.message end end - desc 'Runs the "down" for a given migration VERSION across all tenants.' - task :down do + desc('Runs the "down" for a given migration VERSION across all tenants.') + task(down: :environment) do Apartment::TaskHelper.warn_if_tenants_empty - version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil - raise 'VERSION is required' unless version + version = ENV['VERSION']&.to_i + raise('VERSION is required') unless version Apartment::TaskHelper.each_tenant do |tenant| puts("Migrating #{tenant} tenant down") - Apartment::Migrator.run :down, tenant, version + Apartment::Migrator.run(:down, tenant, version) rescue Apartment::TenantNotFound => e puts e.message end end - desc 'Rolls back the tenant one migration and re migrate up (options: STEP=x, VERSION=x).' - task :redo do + desc('Rolls back the tenant one migration and re migrate up (options: STEP=x, VERSION=x).') + task(:redo) do if ENV['VERSION'] apartment_namespace['migrate:down'].invoke apartment_namespace['migrate:up'].invoke diff --git a/ros-apartment.gemspec b/ros-apartment.gemspec index ecd84593..7a73f2b6 100644 --- a/ros-apartment.gemspec +++ b/ros-apartment.gemspec @@ -32,9 +32,9 @@ Gem::Specification.new do |s| s.required_ruby_version = '>= 3.1' - s.add_dependency('activerecord', '>= 6.1.0', '< 8.1') - s.add_dependency('activesupport', '>= 6.1.0', '< 8.1') + s.add_dependency('activerecord', '>= 6.1.0', '< 8.2') + s.add_dependency('activesupport', '>= 6.1.0', '< 8.2') s.add_dependency('parallel', '< 2.0') - s.add_dependency('public_suffix', '>= 2.0.5', '<= 6.0.1') + s.add_dependency('public_suffix', '>= 2.0.5', '< 7') s.add_dependency('rack', '>= 1.3.6', '< 4.0') end diff --git a/spec/CLAUDE.md b/spec/CLAUDE.md new file mode 100644 index 00000000..d23ff77d --- /dev/null +++ b/spec/CLAUDE.md @@ -0,0 +1,278 @@ +# spec/ - Apartment Test Suite + +This directory contains the test suite for Apartment v3, covering adapters, elevators, configuration, and integration scenarios. + +## Directory Structure + +``` +spec/ +├── adapters/ # Database adapter specs (PostgreSQL, MySQL, SQLite) +├── apartment/ # Core module specs +├── config/ # Database configuration for tests +├── dummy/ # Rails dummy app for integration testing +├── dummy_engine/ # Rails engine for testing engine integration +├── examples/ # Shared example groups for adapter testing +├── integration/ # Full-stack integration tests +├── schemas/ # Test schema fixtures +├── shared_examples/ # Reusable RSpec shared examples +├── support/ # Test helpers and configuration +├── tasks/ # Rake task specs +├── unit/ # Unit tests (elevators, migrator, config) +├── apartment_spec.rb # Main Apartment module specs +├── spec_helper.rb # RSpec configuration +└── tenant_spec.rb # Apartment::Tenant public API specs +``` + +## Test Organization + +### Adapter Tests (spec/adapters/) + +**Purpose**: Test database-specific tenant operations + +**Files**: +- `postgresql_adapter_spec.rb` - PostgreSQL schema isolation +- `mysql2_adapter_spec.rb` - MySQL database isolation +- `sqlite3_adapter_spec.rb` - SQLite file isolation +- `trilogy_adapter_spec.rb` - Trilogy MySQL driver +- `abstract_adapter_spec.rb` - Shared adapter behavior + +**What's tested**: +- Tenant creation/deletion +- Schema import and seeding +- Tenant switching +- Error handling (TenantExists, TenantNotFound) +- Excluded model behavior +- Callbacks + +**See**: `spec/adapters/` for test implementations. + +### Elevator Tests (spec/unit/elevators/) + +**Purpose**: Test Rack middleware tenant detection + +**Files**: +- `generic_spec.rb` - Base elevator with Proc +- `subdomain_spec.rb` - Subdomain-based switching +- `first_subdomain_spec.rb` - First subdomain extraction +- `domain_spec.rb` - Domain-based switching +- `host_spec.rb` - Full hostname switching +- `host_hash_spec.rb` - Hash-based tenant mapping + +**What's tested**: +- Tenant name parsing from requests +- Exclusion logic +- Middleware integration +- Error handling + +**See**: `spec/unit/elevators/` for test implementations. + +### Integration Tests (spec/integration/) + +**Purpose**: Full-stack scenarios with real database operations + +**What's tested**: +- Complete request → response flows +- Middleware + adapter interaction +- Multi-tenant data isolation +- Concurrent tenant access +- Migration scenarios + +**See**: `spec/integration/` for test implementations. + +### Dummy App (spec/dummy/) + +**Purpose**: Minimal Rails app for integration testing + +**Contents**: +- Rails application structure +- Models: User, Company (excluded model) +- Migrations +- Seeds +- Configuration + +**Usage**: Tests run within this Rails context to verify real-world behavior. + +## Test Configuration + +### spec_helper.rb + +**Responsibilities**: +- RSpec configuration +- Database setup/teardown +- Test database selection (PostgreSQL, MySQL, SQLite) +- Shared helper loading +- Apartment configuration for tests + +**See**: `spec/spec_helper.rb` for complete configuration. + +### Database Configuration (spec/config/) + +**Files**: +- `database.yml` - Multi-database configuration +- Environment-specific configs + +**Databases supported**: +- PostgreSQL (default) +- MySQL +- SQLite + +**Selection**: Via `DB` environment variable (`DB=postgresql`, `DB=mysql`, `DB=sqlite3`) + +## Shared Examples (spec/examples/) + +**Why shared examples?**: Apartment promises a unified API regardless of database. Without shared examples, behavior could diverge between PostgreSQL and MySQL implementations. + +**How they enforce contracts**: Each adapter must pass identical tests. If PostgreSQL adapter can create/switch/drop tenants, MySQL adapter must too. Prevents "works on PostgreSQL but breaks on MySQL" scenarios. + +**Files**: +- `adapter_examples.rb` - Common adapter behavior +- `schema_examples.rb` - Schema import/export +- `seed_examples.rb` - Seed data handling + +**Trade-off**: More test code to maintain, but ensures cross-database compatibility. + +**See**: `spec/examples/` for shared example implementations. + +## Support Files (spec/support/) + +Test utility modules for tenant creation/cleanup, database-specific helpers, and common test patterns. + +**See**: `spec/support/` for helper implementations. + +## Test Architecture Decisions + +**Why database-specific test suites?**: Each adapter has fundamentally different isolation mechanisms (PostgreSQL schemas vs MySQL databases vs SQLite files). Testing all adapters against shared examples ensures consistent behavior across implementations. + +**Why `DB` environment variable?**: Allows testing same codebase against different databases without changing configuration. Critical for ensuring gem works across all supported databases. + +**Commands**: See README.md for specific test execution commands. + +## Common Test Patterns + +### Testing Tenant Isolation + +Create multiple tenants, add data in one, verify it doesn't appear in others. See `spec/integration/` for isolation test examples. + +### Testing Callbacks + +Set callbacks on adapter, trigger tenant operations, verify callbacks execute. See `spec/adapters/abstract_adapter_spec.rb`. + +### Testing Error Handling + +Use `expect { }.to raise_error(Apartment::TenantNotFound)` pattern for exception testing. See adapter specs for error handling examples. + +### Testing Excluded Models + +Configure excluded models, create data in one tenant, verify global accessibility. See `spec/apartment/` for excluded model tests. + +### Testing Thread Safety + +Spawn threads with different tenants, verify isolation maintained. See `spec/integration/` for thread safety patterns. + +## Test Data Management + +### Creating Test Tenants + +Use `before` hooks to create test tenants array and `after` hooks to clean up. See `spec/support/apartment_helpers.rb` for helper patterns. + +### Using Factories + +Use FactoryBot within tenant switch blocks. Define factories in `spec/support/factories.rb`. + +## Testing Anti-Patterns + +### ❌ Not Cleaning Up Tenants + +**Problem**: Leaves test tenants in database + +**Fix**: Always clean up in `after` hook. See `spec_helper.rb` for cleanup patterns. + +### ❌ Not Resetting Tenant Context + +**Problem**: Test leaves tenant context changed + +**Fix**: Use `before { Apartment::Tenant.reset }` or block-based switching. See `spec_helper.rb` for reset configuration. + +### ❌ Database-Specific Tests Without Conditionals + +**Problem**: PostgreSQL-only tests run on all databases + +**Fix**: Use conditional tests with `if: postgresql?` guards. See `spec/adapters/` for examples. + +## Debugging Tests + +### Enable Verbose Logging + +Set `config.active_record_log = true` and configure `ActiveRecord::Base.logger`. See `spec_helper.rb` for configuration patterns. + +### Inspect Tenant State + +Use `Apartment::Tenant.current`, `Apartment.tenant_names`, and `Apartment::Tenant.adapter.class` for debugging. + +### Database Inspection + +Query `information_schema.schemata` (PostgreSQL) or `SHOW DATABASES` (MySQL) to inspect tenant state. See adapter specs for examples. + +## Known Issues & Workarounds + +### Issue: Tests Fail Due to Tenant Leakage + +**Symptom**: Random test failures, tenants from previous tests exist + +**Cause**: Inadequate cleanup in `after` hooks + +**Solution**: Force cleanup in `after(:each)` hooks. Reset tenant and drop all test tenants by prefix. See `spec_helper.rb`. + +### Issue: Database Connection Exhaustion + +**Symptom**: Tests hang or fail with connection errors + +**Cause**: Too many simultaneous tenant switches (MySQL) + +**Solution**: Reduce parallelization or increase connection pool size in `spec/config/database.yml`. + +### Issue: Slow Test Suite + +**Symptom**: Tests take minutes to run + +**Causes**: Creating/dropping tenants repeatedly, not using transactions, running full migrations + +**Solutions**: Use transactional fixtures, cache test tenant creation in `before(:suite)`, share tenants for read-only tests. See `spec_helper.rb` for patterns. + +## Test Coverage + +Current coverage areas: +- ✅ Adapter operations (create, switch, drop) +- ✅ Elevator tenant detection +- ✅ Configuration handling +- ✅ Excluded models +- ✅ Callbacks +- ✅ Error handling +- ⚠️ Thread safety (some coverage) +- ⚠️ Migration scenarios (partial) +- ❌ Fiber safety (not tested in v3) + +Areas needing more coverage: +- Concurrent tenant access patterns +- Large-scale tenant creation (100+ tenants) +- Connection pool behavior under load +- Memory leak detection +- Performance benchmarks + +## Best Practices + +1. **Always clean up**: Drop test tenants in `after` hooks +2. **Reset tenant context**: Use `before { Apartment::Tenant.reset }` +3. **Use block-based switching**: Ensures automatic cleanup +4. **Isolate database-specific tests**: Use conditionals for adapter-specific behavior +5. **Mock external dependencies**: Don't hit real external services +6. **Use shared examples**: Ensure consistent adapter behavior +7. **Test error paths**: Not just happy paths +8. **Document why, not what**: Comments should explain intent + +## References + +- RSpec documentation: https://rspec.info/ +- FactoryBot: https://github.com/thoughtbot/factory_bot +- Database Cleaner: https://github.com/DatabaseCleaner/database_cleaner +- Rack::Test: https://github.com/rack/rack-test diff --git a/spec/adapters/jdbc_mysql_adapter_spec.rb b/spec/adapters/jdbc_mysql_adapter_spec.rb index 7c6ad78c..e041fdbf 100644 --- a/spec/adapters/jdbc_mysql_adapter_spec.rb +++ b/spec/adapters/jdbc_mysql_adapter_spec.rb @@ -9,9 +9,7 @@ subject(:adapter) { Apartment::Tenant.adapter } def tenant_names - ActiveRecord::Base.connection.execute('SELECT SCHEMA_NAME FROM information_schema.schemata').collect do |row| - row['SCHEMA_NAME'] - end + ActiveRecord::Base.connection.execute('SELECT SCHEMA_NAME FROM information_schema.schemata').pluck('SCHEMA_NAME') end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } diff --git a/spec/adapters/jdbc_postgresql_adapter_spec.rb b/spec/adapters/jdbc_postgresql_adapter_spec.rb index 7b6d02da..8339798e 100644 --- a/spec/adapters/jdbc_postgresql_adapter_spec.rb +++ b/spec/adapters/jdbc_postgresql_adapter_spec.rb @@ -15,7 +15,7 @@ # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test def tenant_names - ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').collect { |row| row['nspname'] } + ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').pluck('nspname') end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.delete('"') } } @@ -29,7 +29,7 @@ def tenant_names # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test def tenant_names - connection.execute('select datname from pg_database;').collect { |row| row['datname'] } + connection.execute('select datname from pg_database;').pluck('datname') end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } diff --git a/spec/adapters/mysql2_adapter_spec.rb b/spec/adapters/mysql2_adapter_spec.rb index 7c2eed9c..ddda30a2 100644 --- a/spec/adapters/mysql2_adapter_spec.rb +++ b/spec/adapters/mysql2_adapter_spec.rb @@ -9,9 +9,7 @@ subject(:adapter) { Apartment::Tenant.adapter } def tenant_names - ActiveRecord::Base.connection.execute('SELECT schema_name FROM information_schema.schemata').collect do |row| - row[0] - end + ActiveRecord::Base.connection.execute('SELECT schema_name FROM information_schema.schemata').pluck(0) end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } @@ -25,7 +23,7 @@ def tenant_names describe '#default_tenant' do it 'is set to the original db from config' do - expect(subject.default_tenant).to eq(config[:database]) + expect(subject.default_tenant).to(eq(config[:database])) end end @@ -49,7 +47,7 @@ def tenant_names it 'processes model exclusions' do Apartment::Tenant.init - expect(Company.table_name).to eq("#{default_tenant}.companies") + expect(Company.table_name).to(eq("#{default_tenant}.companies")) end end end diff --git a/spec/adapters/postgresql_adapter_spec.rb b/spec/adapters/postgresql_adapter_spec.rb index 6cd0c61c..2add62e5 100644 --- a/spec/adapters/postgresql_adapter_spec.rb +++ b/spec/adapters/postgresql_adapter_spec.rb @@ -15,7 +15,7 @@ # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test def tenant_names - ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').collect { |row| row['nspname'] } + ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').pluck('nspname') end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.delete('"') } } @@ -31,12 +31,12 @@ def tenant_names end after do - Apartment::Tenant.drop('has-dashes') if Apartment.connection.schema_exists? 'has-dashes' + Apartment::Tenant.drop('has-dashes') if Apartment.connection.schema_exists?('has-dashes') end # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test def tenant_names - ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').collect { |row| row['nspname'] } + ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').pluck('nspname') end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.delete('"') } } @@ -45,7 +45,7 @@ def tenant_names it_behaves_like 'a schema based apartment adapter' it 'allows for dashes in the schema name' do - expect { Apartment::Tenant.create('has-dashes') }.not_to raise_error + expect { Apartment::Tenant.create('has-dashes') }.not_to(raise_error) end end @@ -54,7 +54,7 @@ def tenant_names # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test def tenant_names - connection.execute('select datname from pg_database;').collect { |row| row['datname'] } + connection.execute('select datname from pg_database;').pluck('datname') end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } @@ -70,7 +70,7 @@ def tenant_names Apartment.use_schemas = true Apartment.use_sql = true Apartment.pg_exclude_clone_tables = true - ActiveRecord::Base.connection.execute <<-PROCEDURE + ActiveRecord::Base.connection.execute(<<-PROCEDURE) CREATE OR REPLACE FUNCTION test_function() RETURNS INTEGER AS $function$ DECLARE r1 INTEGER; @@ -85,7 +85,7 @@ def tenant_names end after do - Apartment::Tenant.drop('has-procedure') if Apartment.connection.schema_exists? 'has-procedure' + Apartment::Tenant.drop('has-procedure') if Apartment.connection.schema_exists?('has-procedure') ActiveRecord::Base.connection.execute('DROP FUNCTION IF EXISTS test_function();') # Apartment::Tenant.init creates per model connection. # Remove the connection after testing not to unintentionally keep the connection across tests. @@ -96,7 +96,7 @@ def tenant_names # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test def tenant_names - ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').collect { |row| row['nspname'] } + ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').pluck('nspname') end let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.delete('"') } } @@ -106,7 +106,6 @@ def tenant_names it_behaves_like 'a generic apartment adapter' it_behaves_like 'a schema based apartment adapter' - # rubocop:disable RSpec/ExampleLength it 'not change excluded_models in the procedure code' do Apartment::Tenant.init Apartment::Tenant.create('has-procedure') diff --git a/spec/adapters/sqlite3_adapter_spec.rb b/spec/adapters/sqlite3_adapter_spec.rb index 5ae4e5cd..b9856dd8 100644 --- a/spec/adapters/sqlite3_adapter_spec.rb +++ b/spec/adapters/sqlite3_adapter_spec.rb @@ -20,18 +20,18 @@ def tenant_names subject.switch { File.basename(Apartment::Test.config['connections']['sqlite']['database'], '.sqlite3') } end + after(:all) { FileUtils.rm_f(Apartment::Test.config['connections']['sqlite']['database']) } + it_behaves_like 'a generic apartment adapter' it_behaves_like 'a connection based apartment adapter' - - after(:all) do - File.delete(Apartment::Test.config['connections']['sqlite']['database']) - end end context 'with prepend and append' do let(:default_dir) { File.expand_path(File.dirname(config[:database])) } + describe '#prepend' do let(:db_name) { 'db_with_prefix' } + before do Apartment.configure do |config| config.prepend_environment = true @@ -40,39 +40,41 @@ def tenant_names end after do - subject.drop db_name + subject.drop(db_name) rescue StandardError => _e nil end - it 'should create a new database' do - subject.create db_name + it 'creates a new database' do + subject.create(db_name) - expect(File.exist?("#{default_dir}/#{Rails.env}_#{db_name}.sqlite3")).to eq true + expect(File.exist?("#{default_dir}/#{Rails.env}_#{db_name}.sqlite3")).to(be(true)) end end describe '#neither' do let(:db_name) { 'db_without_prefix_suffix' } + before do Apartment.configure { |config| config.prepend_environment = config.append_environment = false } end after do - subject.drop db_name + subject.drop(db_name) rescue StandardError => _e nil end - it 'should create a new database' do - subject.create db_name + it 'creates a new database' do + subject.create(db_name) - expect(File.exist?("#{default_dir}/#{db_name}.sqlite3")).to eq true + expect(File.exist?("#{default_dir}/#{db_name}.sqlite3")).to(be(true)) end end describe '#append' do let(:db_name) { 'db_with_suffix' } + before do Apartment.configure do |config| config.prepend_environment = false @@ -81,15 +83,15 @@ def tenant_names end after do - subject.drop db_name + subject.drop(db_name) rescue StandardError => _e nil end - it 'should create a new database' do - subject.create db_name + it 'creates a new database' do + subject.create(db_name) - expect(File.exist?("#{default_dir}/#{db_name}_#{Rails.env}.sqlite3")).to eq true + expect(File.exist?("#{default_dir}/#{db_name}_#{Rails.env}.sqlite3")).to(be(true)) end end end diff --git a/spec/adapters/trilogy_adapter_spec.rb b/spec/adapters/trilogy_adapter_spec.rb index 61eca4d4..b81e83fb 100644 --- a/spec/adapters/trilogy_adapter_spec.rb +++ b/spec/adapters/trilogy_adapter_spec.rb @@ -23,7 +23,7 @@ def tenant_names describe '#default_tenant' do it 'is set to the original db from config' do - expect(subject.default_tenant).to eq(config[:database]) + expect(subject.default_tenant).to(eq(config[:database])) end end @@ -47,7 +47,7 @@ def tenant_names it 'processes model exclusions' do Apartment::Tenant.init - expect(Company.table_name).to eq("#{default_tenant}.companies") + expect(Company.table_name).to(eq("#{default_tenant}.companies")) end end end diff --git a/spec/apartment_spec.rb b/spec/apartment_spec.rb index f90a09f2..41071c4a 100644 --- a/spec/apartment_spec.rb +++ b/spec/apartment_spec.rb @@ -3,11 +3,11 @@ require 'spec_helper' describe Apartment do - it 'should be valid' do - expect(Apartment).to be_a(Module) + it 'is valid' do + expect(described_class).to(be_a(Module)) end - it 'should be a valid app' do - expect(::Rails.application).to be_a(Dummy::Application) + it 'is a valid app' do + expect(Rails.application).to(be_a(Dummy::Application)) end end diff --git a/spec/dummy/config.ru b/spec/dummy/config.ru index 4f079dd4..61ade414 100644 --- a/spec/dummy/config.ru +++ b/spec/dummy/config.ru @@ -2,5 +2,5 @@ # This file is used by Rack-based servers to start the application. -require ::File.expand_path('config/environment', __dir__) +require File.expand_path('config/environment', __dir__) run Dummy::Application diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index 49b5d459..4f03aa50 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -19,7 +19,7 @@ class Application < Rails::Application require 'apartment/elevators/subdomain' require 'apartment/elevators/domain' - config.middleware.use Apartment::Elevators::Subdomain + config.middleware.use(Apartment::Elevators::Subdomain) # Custom directories with classes and modules you want to be autoloadable. config.autoload_paths += %W[#{config.root}/lib] @@ -47,5 +47,11 @@ class Application < Rails::Application # Configure sensitive parameters which will be filtered from the log file. config.filter_parameters += [:password] + + # Use new connection handling for Rails 7.0 only (setting deprecated in 7.0, removed in 7.1) + # This silences the deprecation warning about legacy_connection_handling + if ActiveRecord.version >= Gem::Version.new('7.0.0') && ActiveRecord.version < Gem::Version.new('7.1.0') + config.active_record.legacy_connection_handling = false + end end end diff --git a/spec/dummy/config/boot.rb b/spec/dummy/config/boot.rb index 0c68bf2c..d31bacc3 100644 --- a/spec/dummy/config/boot.rb +++ b/spec/dummy/config/boot.rb @@ -10,4 +10,4 @@ Bundler.setup end -$LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) +$LOAD_PATH.unshift(File.expand_path('../../../lib', __dir__)) diff --git a/spec/dummy/config/initializers/backtrace_silencers.rb b/spec/dummy/config/initializers/backtrace_silencers.rb index d0f0d3b5..4b63f289 100644 --- a/spec/dummy/config/initializers/backtrace_silencers.rb +++ b/spec/dummy/config/initializers/backtrace_silencers.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Be sure to restart your server when you modify this file. # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. diff --git a/spec/dummy/config/initializers/inflections.rb b/spec/dummy/config/initializers/inflections.rb index 8138cabc..73732d87 100644 --- a/spec/dummy/config/initializers/inflections.rb +++ b/spec/dummy/config/initializers/inflections.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format diff --git a/spec/dummy/config/initializers/mime_types.rb b/spec/dummy/config/initializers/mime_types.rb index f75864f9..df5ec138 100644 --- a/spec/dummy/config/initializers/mime_types.rb +++ b/spec/dummy/config/initializers/mime_types.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Add new mime types for use in respond_to blocks: diff --git a/spec/dummy/config/initializers/session_store.rb b/spec/dummy/config/initializers/session_store.rb index 66099cf5..fa664da9 100644 --- a/spec/dummy/config/initializers/session_store.rb +++ b/spec/dummy/config/initializers/session_store.rb @@ -2,7 +2,7 @@ # Be sure to restart your server when you modify this file. -Dummy::Application.config.session_store :cookie_store, key: '_dummy_session' +Dummy::Application.config.session_store(:cookie_store, key: '_dummy_session') # Use the database for sessions instead of the cookie-based default, # which shouldn't be used to store highly confidential information diff --git a/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb b/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb index f66e40f1..2514bc7a 100644 --- a/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb +++ b/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb @@ -2,37 +2,37 @@ class CreateDummyModels < ActiveRecord::Migration[4.2] def self.up - create_table :companies do |t| - t.boolean :dummy - t.string :database + create_table(:companies) do |t| + t.boolean(:dummy) + t.string(:database) end - create_table :users do |t| - t.string :name - t.datetime :birthdate - t.string :sex + create_table(:users) do |t| + t.string(:name) + t.datetime(:birthdate) + t.string(:sex) end - create_table :delayed_jobs do |t| - t.integer :priority, default: 0 - t.integer :attempts, default: 0 - t.text :handler - t.text :last_error - t.datetime :run_at - t.datetime :locked_at - t.datetime :failed_at - t.string :locked_by - t.datetime :created_at - t.datetime :updated_at - t.string :queue + create_table(:delayed_jobs) do |t| + t.integer(:priority, default: 0) + t.integer(:attempts, default: 0) + t.text(:handler) + t.text(:last_error) + t.datetime(:run_at) + t.datetime(:locked_at) + t.datetime(:failed_at) + t.string(:locked_by) + t.datetime(:created_at) + t.datetime(:updated_at) + t.string(:queue) end - add_index 'delayed_jobs', %w[priority run_at], name: 'delayed_jobs_priority' + add_index('delayed_jobs', %w[priority run_at], name: 'delayed_jobs_priority') end def self.down - drop_table :companies - drop_table :users - drop_table :delayed_jobs + drop_table(:companies) + drop_table(:users) + drop_table(:delayed_jobs) end end diff --git a/spec/dummy/db/migrate/20111202022214_create_table_books.rb b/spec/dummy/db/migrate/20111202022214_create_table_books.rb index 9957f53e..8133ab4f 100644 --- a/spec/dummy/db/migrate/20111202022214_create_table_books.rb +++ b/spec/dummy/db/migrate/20111202022214_create_table_books.rb @@ -2,14 +2,14 @@ class CreateTableBooks < ActiveRecord::Migration[4.2] def up - create_table :books do |t| - t.string :name - t.integer :pages - t.datetime :published + create_table(:books) do |t| + t.string(:name) + t.integer(:pages) + t.datetime(:published) end end def down - drop_table :books + drop_table(:books) end end diff --git a/spec/dummy/db/migrate/20180415260934_create_public_tokens.rb b/spec/dummy/db/migrate/20180415260934_create_public_tokens.rb index f2ad8291..cc2e0bb6 100644 --- a/spec/dummy/db/migrate/20180415260934_create_public_tokens.rb +++ b/spec/dummy/db/migrate/20180415260934_create_public_tokens.rb @@ -2,13 +2,13 @@ class CreatePublicTokens < ActiveRecord::Migration[4.2] def up - create_table :public_tokens do |t| - t.string :token - t.integer :user_id, foreign_key: true + create_table(:public_tokens) do |t| + t.string(:token) + t.integer(:user_id, foreign_key: true) end end def down - drop_table :public_tokens + drop_table(:public_tokens) end end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index ab0d1c00..57e5e99d 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -14,42 +14,42 @@ ActiveRecord::Schema.define(version: 20_180_415_260_934) do # These are extensions that must be enabled in order to support this database - enable_extension 'plpgsql' + enable_extension 'plpgsql' if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' create_table 'books', force: :cascade do |t| - t.string 'name' - t.integer 'pages' - t.datetime 'published' + t.string('name') + t.integer('pages') + t.datetime('published') end create_table 'companies', force: :cascade do |t| - t.boolean 'dummy' - t.string 'database' + t.boolean('dummy') + t.string('database') end create_table 'delayed_jobs', force: :cascade do |t| - t.integer 'priority', default: 0 - t.integer 'attempts', default: 0 - t.text 'handler' - t.text 'last_error' - t.datetime 'run_at' - t.datetime 'locked_at' - t.datetime 'failed_at' - t.string 'locked_by' - t.datetime 'created_at' - t.datetime 'updated_at' - t.string 'queue' - t.index %w[priority run_at], name: 'delayed_jobs_priority' + t.integer('priority', default: 0) + t.integer('attempts', default: 0) + t.text('handler') + t.text('last_error') + t.datetime('run_at') + t.datetime('locked_at') + t.datetime('failed_at') + t.string('locked_by') + t.datetime('created_at') + t.datetime('updated_at') + t.string('queue') + t.index(%w[priority run_at], name: 'delayed_jobs_priority') end create_table 'public_tokens', id: :serial, force: :cascade do |t| - t.string 'token' - t.integer 'user_id' + t.string('token') + t.integer('user_id') end create_table 'users', force: :cascade do |t| - t.string 'name' - t.datetime 'birthdate' - t.string 'sex' + t.string('name') + t.datetime('birthdate') + t.string('sex') end end diff --git a/spec/dummy_engine/Rakefile b/spec/dummy_engine/Rakefile index 72a61a74..3fcb3c9b 100644 --- a/spec/dummy_engine/Rakefile +++ b/spec/dummy_engine/Rakefile @@ -1,7 +1,7 @@ # frozen_string_literal: true begin - require 'bundler/setup' + require('bundler/setup') rescue LoadError puts 'You must `gem install bundler` and `bundle install` to run rake tasks' end diff --git a/spec/dummy_engine/config/initializers/apartment.rb b/spec/dummy_engine/config/initializers/apartment.rb index 419f12dd..e5b2e607 100644 --- a/spec/dummy_engine/config/initializers/apartment.rb +++ b/spec/dummy_engine/config/initializers/apartment.rb @@ -50,4 +50,4 @@ # Rails.application.config.middleware.use Apartment::Elevators::Domain -Rails.application.config.middleware.use Apartment::Elevators::Subdomain +Rails.application.config.middleware.use(Apartment::Elevators::Subdomain) diff --git a/spec/dummy_engine/test/dummy/config.ru b/spec/dummy_engine/test/dummy/config.ru index 667e328d..afd13e21 100644 --- a/spec/dummy_engine/test/dummy/config.ru +++ b/spec/dummy_engine/test/dummy/config.ru @@ -2,5 +2,5 @@ # This file is used by Rack-based servers to start the application. -require ::File.expand_path('config/environment', __dir__) +require File.expand_path('config/environment', __dir__) run Rails.application diff --git a/spec/dummy_engine/test/dummy/config/boot.rb b/spec/dummy_engine/test/dummy/config/boot.rb index 6d2cba07..2c548c94 100644 --- a/spec/dummy_engine/test/dummy/config/boot.rb +++ b/spec/dummy_engine/test/dummy/config/boot.rb @@ -4,4 +4,4 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) -$LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) +$LOAD_PATH.unshift(File.expand_path('../../../lib', __dir__)) diff --git a/spec/dummy_engine/test/dummy/config/environments/production.rb b/spec/dummy_engine/test/dummy/config/environments/production.rb index 1bd152f1..0d99316d 100644 --- a/spec/dummy_engine/test/dummy/config/environments/production.rb +++ b/spec/dummy_engine/test/dummy/config/environments/production.rb @@ -73,7 +73,7 @@ # config.autoflush_log = false # Use default logging formatter so that PID and timestamp are not suppressed. - config.log_formatter = ::Logger::Formatter.new + config.log_formatter = Logger::Formatter.new # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false diff --git a/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb b/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb index d0f0d3b5..4b63f289 100644 --- a/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb +++ b/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Be sure to restart your server when you modify this file. # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. diff --git a/spec/dummy_engine/test/dummy/config/initializers/inflections.rb b/spec/dummy_engine/test/dummy/config/initializers/inflections.rb index aa7435fb..dc847422 100644 --- a/spec/dummy_engine/test/dummy/config/initializers/inflections.rb +++ b/spec/dummy_engine/test/dummy/config/initializers/inflections.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format. Inflections diff --git a/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb b/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb index 6e1d16f0..be6fedc5 100644 --- a/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb +++ b/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Add new mime types for use in respond_to blocks: diff --git a/spec/dummy_engine/test/dummy/config/initializers/session_store.rb b/spec/dummy_engine/test/dummy/config/initializers/session_store.rb index 969d977f..e05bcba4 100644 --- a/spec/dummy_engine/test/dummy/config/initializers/session_store.rb +++ b/spec/dummy_engine/test/dummy/config/initializers/session_store.rb @@ -2,4 +2,4 @@ # Be sure to restart your server when you modify this file. -Rails.application.config.session_store :cookie_store, key: '_dummy_session' +Rails.application.config.session_store(:cookie_store, key: '_dummy_session') diff --git a/spec/examples/connection_adapter_examples.rb b/spec/examples/connection_adapter_examples.rb index 973ed1fa..1d12c4c1 100644 --- a/spec/examples/connection_adapter_examples.rb +++ b/spec/examples/connection_adapter_examples.rb @@ -16,29 +16,29 @@ end end - it 'should process model exclusions' do + it 'processes model exclusions' do Apartment.configure do |config| config.excluded_models = ['Company'] end Apartment::Tenant.init - expect(Company.connection.object_id).not_to eq(ActiveRecord::Base.connection.object_id) + expect(Company.connection.object_id).not_to(eq(ActiveRecord::Base.connection.object_id)) end end describe '#drop' do - it 'should raise an error for unknown database' do + it 'raises an error for unknown database' do expect do - subject.drop 'unknown_database' - end.to raise_error(Apartment::TenantNotFound) + subject.drop('unknown_database') + end.to(raise_error(Apartment::TenantNotFound)) end end describe '#switch!' do - it 'should raise an error if database is invalid' do + it 'raises an error if database is invalid' do expect do - subject.switch! 'unknown_database' - end.to raise_error(Apartment::TenantNotFound) + subject.switch!('unknown_database') + end.to(raise_error(Apartment::TenantNotFound)) end end end diff --git a/spec/examples/generic_adapter_custom_configuration_example.rb b/spec/examples/generic_adapter_custom_configuration_example.rb index 2ad7ee20..4f451c0e 100644 --- a/spec/examples/generic_adapter_custom_configuration_example.rb +++ b/spec/examples/generic_adapter_custom_configuration_example.rb @@ -7,7 +7,7 @@ let(:db) { |example| example.metadata[:database] } let(:custom_tenant_names) do { - custom_tenant_name => custom_db_conf + custom_tenant_name => custom_db_conf, } end @@ -24,26 +24,26 @@ let(:expected_args) { custom_db_conf } describe '#create' do - it 'should establish_connection with the separate connection with expected args' do + it 'establish_connections with the separate connection with expected args' do expect(Apartment::Adapters::AbstractAdapter::SeparateDbConnectionHandler).to( receive(:establish_connection).with(expected_args).and_call_original ) # because we don't have another server to connect to it errors # what matters is establish_connection receives proper args - expect { subject.create(custom_tenant_name) }.to raise_error(Apartment::TenantExists) + expect { subject.create(custom_tenant_name) }.to(raise_error(Apartment::TenantExists)) end end describe '#drop' do - it 'should establish_connection with the separate connection with expected args' do + it 'establish_connections with the separate connection with expected args' do expect(Apartment::Adapters::AbstractAdapter::SeparateDbConnectionHandler).to( receive(:establish_connection).with(expected_args).and_call_original ) # because we dont have another server to connect to it errors # what matters is establish_connection receives proper args - expect { subject.drop(custom_tenant_name) }.to raise_error(Apartment::TenantNotFound) + expect { subject.drop(custom_tenant_name) }.to(raise_error(Apartment::TenantNotFound)) end end end @@ -54,19 +54,19 @@ end describe '#switch!' do - it 'should connect to new db' do - expect(Apartment).to receive(:establish_connection) do |args| + it 'connects to new db' do + expect(Apartment).to(receive(:establish_connection)) do |args| db_name = args.delete(:database) - expect(args).to eq expected_args - expect(db_name).to match custom_tenant_name + expect(args).to(eq(expected_args)) + expect(db_name).to(match(custom_tenant_name)) # we only need to check args, then we short circuit # in order to avoid the mess due to the `establish_connection` override raise ActiveRecord::ActiveRecordError end - expect { subject.switch!(custom_tenant_name) }.to raise_error(Apartment::TenantNotFound) + expect { subject.switch!(custom_tenant_name) }.to(raise_error(Apartment::TenantNotFound)) end end end @@ -77,17 +77,17 @@ def specific_connection adapter: 'postgresql', database: 'override_database', password: 'override_password', - username: 'overridepostgres' + username: 'overridepostgres', }, mysql: { adapter: 'mysql2', database: 'override_database', - username: 'root' + username: 'root', }, sqlite: { adapter: 'sqlite3', - database: 'override_database' - } + database: 'override_database', + }, } end diff --git a/spec/examples/generic_adapter_examples.rb b/spec/examples/generic_adapter_examples.rb index 7999a6d2..2622144e 100644 --- a/spec/examples/generic_adapter_examples.rb +++ b/spec/examples/generic_adapter_examples.rb @@ -12,7 +12,7 @@ end describe '#init' do - it 'should not connect if env var is set' do + it 'does not connect if env var is set' do ENV['APARTMENT_DISABLE_INIT'] = 'true' begin ActiveRecord::Base.connection_pool.disconnect! @@ -20,11 +20,11 @@ Apartment::Railtie.config.to_prepare_blocks.map(&:call) num_available_connections = Apartment.connection_class.connection_pool - .instance_variable_get(:@available) - .instance_variable_get(:@queue) - .size + .instance_variable_get(:@available) + .instance_variable_get(:@queue) + .size - expect(num_available_connections).to eq(0) + expect(num_available_connections).to(eq(0)) ensure ENV.delete('APARTMENT_DISABLE_INIT') end @@ -35,34 +35,34 @@ # Creates happen already in our before_filter # describe '#create' do - it 'should create the new databases' do - expect(tenant_names).to include(db1) - expect(tenant_names).to include(db2) + it 'creates the new databases' do + expect(tenant_names).to(include(db1)) + expect(tenant_names).to(include(db2)) end - it 'should load schema.rb to new schema' do + it 'loads schema.rb to new schema' do subject.switch(db1) do - expect(connection.tables).to include('users') + expect(connection.tables).to(include('users')) end end - it 'should yield to block if passed and reset' do + it 'yields to block if passed and reset' do subject.drop(db2) # so we don't get errors on creation @count = 0 # set our variable so its visible in and outside of blocks subject.create(db2) do @count = User.count - expect(subject.current).to eq(db2) + expect(subject.current).to(eq(db2)) User.create end - expect(subject.current).not_to eq(db2) + expect(subject.current).not_to(eq(db2)) - subject.switch(db2) { expect(User.count).to eq(@count + 1) } + subject.switch(db2) { expect(User.count).to(eq(@count + 1)) } end - it 'should raise error when the schema.rb is missing unless Apartment.use_sql is set to true' do + it 'raises error when the schema.rb is missing unless Apartment.use_sql is set to true' do next if Apartment.use_sql subject.drop(db1) @@ -71,7 +71,7 @@ Apartment.database_schema_file = "#{tmpdir}/schema.rb" expect do subject.create(db1) - end.to raise_error(Apartment::FileNotFound) + end.to(raise_error(Apartment::FileNotFound)) end ensure Apartment.remove_instance_variable(:@database_schema_file) @@ -80,61 +80,61 @@ end describe '#drop' do - it 'should remove the db' do - subject.drop db1 - expect(tenant_names).not_to include(db1) + it 'removes the db' do + subject.drop(db1) + expect(tenant_names).not_to(include(db1)) end end describe '#switch!' do - it 'should connect to new db' do + it 'connects to new db' do subject.switch!(db1) - expect(subject.current).to eq(db1) + expect(subject.current).to(eq(db1)) end - it 'should reset connection if database is nil' do + it 'resets connection if database is nil' do subject.switch! - expect(subject.current).to eq(default_tenant) + expect(subject.current).to(eq(default_tenant)) end - it 'should raise an error if database is invalid' do + it 'raises an error if database is invalid' do expect do - subject.switch! 'unknown_database' - end.to raise_error(Apartment::TenantNotFound) + subject.switch!('unknown_database') + end.to(raise_error(Apartment::TenantNotFound)) end end describe '#switch' do it 'connects and resets the tenant' do subject.switch(db1) do - expect(subject.current).to eq(db1) + expect(subject.current).to(eq(db1)) end - expect(subject.current).to eq(default_tenant) + expect(subject.current).to(eq(default_tenant)) end # We're often finding when using Apartment in tests, the `current` (ie the previously connect to db) # gets dropped, but switch will try to return to that db in a test. We should just reset if it doesn't exist - it 'should not throw exception if current is no longer accessible' do + it 'does not throw exception if current is no longer accessible' do subject.switch!(db2) expect do subject.switch(db1) { subject.drop(db2) } - end.not_to raise_error + end.not_to(raise_error) end end describe '#reset' do - it 'should reset connection' do + it 'resets connection' do subject.switch!(db1) subject.reset - expect(subject.current).to eq(default_tenant) + expect(subject.current).to(eq(default_tenant)) end end describe '#current' do - it 'should return the current db name' do + it 'returns the current db name' do subject.switch!(db1) - expect(subject.current).to eq(db1) + expect(subject.current).to(eq(db1)) end end @@ -145,10 +145,10 @@ subject.each do |tenant| result << tenant - expect(subject.current).to eq(tenant) + expect(subject.current).to(eq(tenant)) end - expect(result).to eq([db2, db1]) + expect(result).to(eq([db2, db1])) end it 'iterates over the given tenants' do @@ -157,10 +157,10 @@ subject.each([db2]) do |tenant| result << tenant - expect(subject.current).to eq(tenant) + expect(subject.current).to(eq(tenant)) end - expect(result).to eq([db2]) + expect(result).to(eq([db2])) end end end diff --git a/spec/examples/generic_adapters_callbacks_examples.rb b/spec/examples/generic_adapters_callbacks_examples.rb index 3184a4a1..0f5b1968 100644 --- a/spec/examples/generic_adapters_callbacks_examples.rb +++ b/spec/examples/generic_adapters_callbacks_examples.rb @@ -18,22 +18,22 @@ def self.call(tenant_name); end describe '#switch!' do before do - Apartment::Adapters::AbstractAdapter.set_callback :switch, :before do + Apartment::Adapters::AbstractAdapter.set_callback(:switch, :before) do MyProc.call(Apartment::Tenant.current) end - Apartment::Adapters::AbstractAdapter.set_callback :switch, :after do + Apartment::Adapters::AbstractAdapter.set_callback(:switch, :after) do MyProc.call(Apartment::Tenant.current) end - allow(MyProc).to receive(:call) + allow(MyProc).to(receive(:call)) end # NOTE: Part of the test setup creates and switches tenants, so we need # to reset the callbacks to ensure that each test run has the correct # counts after do - Apartment::Adapters::AbstractAdapter.reset_callbacks :switch + Apartment::Adapters::AbstractAdapter.reset_callbacks(:switch) end context 'when tenant is nil' do @@ -42,7 +42,7 @@ def self.call(tenant_name); end end it 'runs both before and after callbacks' do - expect(MyProc).to have_received(:call).twice + expect(MyProc).to(have_received(:call).twice) end end @@ -52,7 +52,7 @@ def self.call(tenant_name); end end it 'runs both before and after callbacks' do - expect(MyProc).to have_received(:call).twice + expect(MyProc).to(have_received(:call).twice) end end end diff --git a/spec/examples/schema_adapter_examples.rb b/spec/examples/schema_adapter_examples.rb index 452eb6f2..65f825ac 100644 --- a/spec/examples/schema_adapter_examples.rb +++ b/spec/examples/schema_adapter_examples.rb @@ -23,36 +23,38 @@ end end - it 'should process model exclusions' do + it 'processes model exclusions' do Apartment::Tenant.init - expect(Company.table_name).to eq('public.companies') - expect(Company.sequence_name).to eq('public.companies_id_seq') - expect(User.table_name).to eq('users') - expect(User.sequence_name).to eq('users_id_seq') + expect(Company.table_name).to(eq('public.companies')) + expect(Company.sequence_name).to(eq('public.companies_id_seq')) + expect(User.table_name).to(eq('users')) + expect(User.sequence_name).to(eq('users_id_seq')) end - context 'with a default_tenant', default_tenant: true do - it 'should set the proper table_name on excluded_models' do + context 'with a default_tenant', :default_tenant do + it 'sets the proper table_name on excluded_models' do Apartment::Tenant.init - expect(Company.table_name).to eq("#{default_tenant}.companies") - expect(Company.sequence_name).to eq("#{default_tenant}.companies_id_seq") - expect(User.table_name).to eq('users') - expect(User.sequence_name).to eq('users_id_seq') + expect(Company.table_name).to(eq("#{default_tenant}.companies")) + expect(Company.sequence_name).to(eq("#{default_tenant}.companies_id_seq")) + expect(User.table_name).to(eq('users')) + expect(User.sequence_name).to(eq('users_id_seq')) end it 'sets the search_path correctly' do Apartment::Tenant.init - expect(User.connection.schema_search_path).to match(/|#{default_tenant}|/) + expect(User.connection.schema_search_path).to(match(/|#{default_tenant}|/)) end end - context 'persistent_schemas', persistent_schemas: true do + context 'persistent_schemas', :persistent_schemas do it 'sets the persistent schemas in the schema_search_path' do Apartment::Tenant.init - expect(connection.schema_search_path).to end_with persistent_schemas.map { |schema| %("#{schema}") }.join(', ') + expect(connection.schema_search_path).to(end_with(persistent_schemas.map do |schema| + %("#{schema}") + end.join(', '))) end end end @@ -61,40 +63,41 @@ # Creates happen already in our before_filter # describe '#create' do - it 'should load schema.rb to new schema' do + it 'loads schema.rb to new schema' do connection.schema_search_path = schema1 - expect(connection.tables).to include('users') + expect(connection.tables).to(include('users')) end - it 'should yield to block if passed and reset' do + it 'yields to block if passed and reset' do subject.drop(schema2) # so we don't get errors on creation @count = 0 # set our variable so its visible in and outside of blocks subject.create(schema2) do @count = User.count - expect(connection.schema_search_path).to start_with %("#{schema2}") + expect(connection.schema_search_path).to(start_with(%("#{schema2}"))) User.create end - expect(connection.schema_search_path).not_to start_with %("#{schema2}") + expect(connection.schema_search_path).not_to(start_with(%("#{schema2}"))) - subject.switch(schema2) { expect(User.count).to eq(@count + 1) } + subject.switch(schema2) { expect(User.count).to(eq(@count + 1)) } end context 'numeric database names' do let(:db) { 1234 } - it 'should allow them' do + + after { subject.drop(db) } + + it 'allows them' do expect do subject.create(db) - end.not_to raise_error - expect(tenant_names).to include(db.to_s) + end.not_to(raise_error) + expect(tenant_names).to(include(db.to_s)) end - - after { subject.drop(db) } end - context 'with a default_tenant', default_tenant: true do + context 'with a default_tenant', :default_tenant do let(:from_default_tenant) { 'new_from_custom_default_tenant' } before do @@ -105,40 +108,40 @@ subject.drop(from_default_tenant) end - it 'should correctly create the new schema' do - expect(tenant_names).to include(from_default_tenant) + it 'correctlies create the new schema' do + expect(tenant_names).to(include(from_default_tenant)) end - it 'should load schema.rb to new schema' do + it 'loads schema.rb to new schema' do connection.schema_search_path = from_default_tenant - expect(connection.tables).to include('users') + expect(connection.tables).to(include('users')) end end end describe '#drop' do - it 'should raise an error for unknown database' do + it 'raises an error for unknown database' do expect do - subject.drop 'unknown_database' - end.to raise_error(Apartment::TenantNotFound) + subject.drop('unknown_database') + end.to(raise_error(Apartment::TenantNotFound)) end context 'numeric database names' do let(:db) { 1234 } - it 'should be able to drop them' do - subject.create(db) - expect do - subject.drop(db) - end.not_to raise_error - expect(tenant_names).not_to include(db.to_s) - end - after do subject.drop(db) rescue StandardError => _e nil end + + it 'is able to drop them' do + subject.create(db) + expect do + subject.drop(db) + end.not_to(raise_error) + expect(tenant_names).not_to(include(db.to_s)) + end end end @@ -155,21 +158,21 @@ Company.reset_sequence_name User.reset_sequence_name - expect(connection.schema_search_path).to start_with %("#{schema1}") - expect(User.sequence_name).to eq "#{User.table_name}_id_seq" - expect(Company.sequence_name).to eq "#{public_schema}.#{Company.table_name}_id_seq" + expect(connection.schema_search_path).to(start_with(%("#{schema1}"))) + expect(User.sequence_name).to(eq("#{User.table_name}_id_seq")) + expect(Company.sequence_name).to(eq("#{public_schema}.#{Company.table_name}_id_seq")) end - expect(connection.schema_search_path).to start_with %("#{public_schema}") - expect(User.sequence_name).to eq "#{User.table_name}_id_seq" - expect(Company.sequence_name).to eq "#{public_schema}.#{Company.table_name}_id_seq" + expect(connection.schema_search_path).to(start_with(%("#{public_schema}"))) + expect(User.sequence_name).to(eq("#{User.table_name}_id_seq")) + expect(Company.sequence_name).to(eq("#{public_schema}.#{Company.table_name}_id_seq")) end describe 'multiple schemas' do it 'allows a list of schemas' do subject.switch([schema1, schema2]) do - expect(connection.schema_search_path).to include %("#{schema1}") - expect(connection.schema_search_path).to include %("#{schema2}") + expect(connection.schema_search_path).to(include(%("#{schema1}"))) + expect(connection.schema_search_path).to(include(%("#{schema2}"))) end end @@ -179,47 +182,49 @@ Company.reset_sequence_name User.reset_sequence_name - expect(connection.schema_search_path).to start_with %("#{schema1}") - expect(User.sequence_name).to eq "#{User.table_name}_id_seq" - expect(Company.sequence_name).to eq "#{public_schema}.#{Company.table_name}_id_seq" + expect(connection.schema_search_path).to(start_with(%("#{schema1}"))) + expect(User.sequence_name).to(eq("#{User.table_name}_id_seq")) + expect(Company.sequence_name).to(eq("#{public_schema}.#{Company.table_name}_id_seq")) end - expect(connection.schema_search_path).to start_with %("#{public_schema}") - expect(User.sequence_name).to eq "#{User.table_name}_id_seq" - expect(Company.sequence_name).to eq "#{public_schema}.#{Company.table_name}_id_seq" + expect(connection.schema_search_path).to(start_with(%("#{public_schema}"))) + expect(User.sequence_name).to(eq("#{User.table_name}_id_seq")) + expect(Company.sequence_name).to(eq("#{public_schema}.#{Company.table_name}_id_seq")) end end end describe '#reset' do - it 'should reset connection' do + it 'resets connection' do subject.switch!(schema1) subject.reset - expect(connection.schema_search_path).to start_with %("#{public_schema}") + expect(connection.schema_search_path).to(start_with(%("#{public_schema}"))) end - context 'with default_tenant', default_tenant: true do - it 'should reset to the default schema' do + context 'with default_tenant', :default_tenant do + it 'resets to the default schema' do subject.switch!(schema1) subject.reset - expect(connection.schema_search_path).to start_with %("#{default_tenant}") + expect(connection.schema_search_path).to(start_with(%("#{default_tenant}"))) end end - context 'persistent_schemas', persistent_schemas: true do + context 'persistent_schemas', :persistent_schemas do before do subject.switch!(schema1) subject.reset end it 'maintains the persistent schemas in the schema_search_path' do - expect(connection.schema_search_path).to end_with persistent_schemas.map { |schema| %("#{schema}") }.join(', ') + expect(connection.schema_search_path).to(end_with(persistent_schemas.map do |schema| + %("#{schema}") + end.join(', '))) end - context 'with default_tenant', default_tenant: true do + context 'with default_tenant', :default_tenant do it 'prioritizes the switched schema to front of schema_search_path' do subject.reset # need to re-call this as the default_tenant wasn't set at the time that the above reset ran - expect(connection.schema_search_path).to start_with %("#{default_tenant}") + expect(connection.schema_search_path).to(start_with(%("#{default_tenant}"))) end end end @@ -230,86 +235,88 @@ before { Apartment.tenant_presence_check = tenant_presence_check } - it 'should connect to new schema' do + it 'connects to new schema' do subject.switch!(schema1) - expect(connection.schema_search_path).to start_with %("#{schema1}") + expect(connection.schema_search_path).to(start_with(%("#{schema1}"))) end - it 'should reset connection if database is nil' do + it 'resets connection if database is nil' do subject.switch! - expect(connection.schema_search_path).to eq(%("#{public_schema}")) + expect(connection.schema_search_path).to(eq(%("#{public_schema}"))) end context 'when configuration checks for tenant presence before switching' do - it 'should raise an error if schema is invalid' do + it 'raises an error if schema is invalid' do expect do - subject.switch! 'unknown_schema' - end.to raise_error(Apartment::TenantNotFound) + subject.switch!('unknown_schema') + end.to(raise_error(Apartment::TenantNotFound)) end end context 'when configuration skips tenant presence check before switching' do let(:tenant_presence_check) { false } - it 'should not raise any errors' do + it 'does not raise any errors' do expect do - subject.switch! 'unknown_schema' - end.not_to raise_error + subject.switch!('unknown_schema') + end.not_to(raise_error) end end context 'numeric databases' do let(:db) { 1234 } - it 'should connect to them' do + after { subject.drop(db) } + + it 'connects to them' do subject.create(db) expect do subject.switch!(db) - end.not_to raise_error + end.not_to(raise_error) - expect(connection.schema_search_path).to start_with %("#{db}") + expect(connection.schema_search_path).to(start_with(%("#{db}"))) end - - after { subject.drop(db) } end - describe 'with default_tenant specified', default_tenant: true do + describe 'with default_tenant specified', :default_tenant do before do subject.switch!(schema1) end - it 'should switch out the default schema rather than public' do - expect(connection.schema_search_path).not_to include default_tenant + it 'switches out the default schema rather than public' do + expect(connection.schema_search_path).not_to(include(default_tenant)) end - it 'should still switch to the switched schema' do - expect(connection.schema_search_path).to start_with %("#{schema1}") + it 'stills switch to the switched schema' do + expect(connection.schema_search_path).to(start_with(%("#{schema1}"))) end end - context 'persistent_schemas', persistent_schemas: true do + context 'persistent_schemas', :persistent_schemas do before { subject.switch!(schema1) } it 'maintains the persistent schemas in the schema_search_path' do - expect(connection.schema_search_path).to end_with persistent_schemas.map { |schema| %("#{schema}") }.join(', ') + expect(connection.schema_search_path).to(end_with(persistent_schemas.map do |schema| + %("#{schema}") + end.join(', '))) end it 'prioritizes the switched schema to front of schema_search_path' do - expect(connection.schema_search_path).to start_with %("#{schema1}") + expect(connection.schema_search_path).to(start_with(%("#{schema1}"))) end end end describe '#current' do - it 'should return the current schema name' do + it 'returns the current schema name' do subject.switch!(schema1) - expect(subject.current).to eq(schema1) + expect(subject.current).to(eq(schema1)) end - context 'persistent_schemas', persistent_schemas: true do - it 'should exlude persistent_schemas' do + context 'persistent_schemas', :persistent_schemas do + it 'exludes persistent_schemas' do subject.switch!(schema1) - expect(subject.current).to eq(schema1) + expect(subject.current).to(eq(schema1)) end end end diff --git a/spec/integration/apartment_rake_integration_spec.rb b/spec/integration/apartment_rake_integration_spec.rb index 530f8146..e493e75b 100644 --- a/spec/integration/apartment_rake_integration_spec.rb +++ b/spec/integration/apartment_rake_integration_spec.rb @@ -23,12 +23,18 @@ config.tenant_names = -> { Company.pluck(:database) } end Apartment::Tenant.reload!(config) - - # fix up table name of shared/excluded models - Company.table_name = 'public.companies' + Apartment::Tenant.init end - after { Rake.application = nil } + after do + Rake.application = nil + + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end + end context 'with x number of databases' do let(:x) { rand(1..5) } # random number of dbs to create @@ -38,7 +44,7 @@ before do db_names.collect do |db_name| Apartment::Tenant.create(db_name) - Company.create database: db_name + Company.create(database: db_name) end end @@ -51,26 +57,26 @@ let(:migration_context_double) { double(:migration_context) } describe '#migrate' do - it 'should migrate all databases' do + it 'migrates all databases' do if ActiveRecord.version >= Gem::Version.new('7.2.0') allow(ActiveRecord::Base.connection_pool) else allow(ActiveRecord::Base.connection) - end.to receive(:migration_context) { migration_context_double } - expect(migration_context_double).to receive(:migrate).exactly(company_count).times + end.to(receive(:migration_context) { migration_context_double }) + expect(migration_context_double).to(receive(:migrate).exactly(company_count).times) @rake['apartment:migrate'].invoke end end describe '#rollback' do - it 'should rollback all dbs' do + it 'rollbacks all dbs' do if ActiveRecord.version >= Gem::Version.new('7.2.0') allow(ActiveRecord::Base.connection_pool) else allow(ActiveRecord::Base.connection) - end.to receive(:migration_context) { migration_context_double } - expect(migration_context_double).to receive(:rollback).exactly(company_count).times + end.to(receive(:migration_context) { migration_context_double }) + expect(migration_context_double).to(receive(:rollback).exactly(company_count).times) @rake['apartment:rollback'].invoke end @@ -78,8 +84,8 @@ end describe 'apartment:seed' do - it 'should seed all databases' do - expect(Apartment::Tenant).to receive(:seed).exactly(company_count).times + it 'seeds all databases' do + expect(Apartment::Tenant).to(receive(:seed).exactly(company_count).times) @rake['apartment:seed'].invoke end diff --git a/spec/integration/connection_handling_spec.rb b/spec/integration/connection_handling_spec.rb index 5b3e10fb..8c7d4d75 100644 --- a/spec/integration/connection_handling_spec.rb +++ b/spec/integration/connection_handling_spec.rb @@ -2,28 +2,37 @@ require 'spec_helper' -describe 'connection handling monkey patch' do +describe 'connection handling monkey patch', database: :postgresql do let(:db_name) { db1 } before do Apartment.configure do |config| config.excluded_models = ['Company'] - config.tenant_names = -> { Company.pluck(:database) } config.use_schemas = true end + Apartment::Tenant.init + Apartment::Tenant.create(db_name) + Company.create(database: db_name) + Apartment.configure do |config| + config.tenant_names = -> { Company.pluck(:database) } + end Apartment::Tenant.reload!(config) - Apartment::Tenant.create(db_name) - Company.create database: db_name - Apartment::Tenant.switch! db_name - User.create! name: db_name + Apartment::Tenant.switch!(db_name) + User.create!(name: db_name) end after do Apartment::Tenant.drop(db_name) Apartment::Tenant.reset Company.delete_all + + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end end context 'when ActiveRecord >= 6.0', if: ActiveRecord::VERSION::MAJOR >= 6 do @@ -36,14 +45,14 @@ end it 'is monkey patched' do - expect(ActiveRecord::ConnectionHandling.instance_methods).to include(:connected_to_with_tenant) + expect(ActiveRecord::ConnectionHandling.instance_methods).to(include(:connected_to_with_tenant)) end it 'switches to the previous set tenant' do - Apartment::Tenant.switch! db_name + Apartment::Tenant.switch!(db_name) ActiveRecord::Base.connected_to(role: role) do - expect(Apartment::Tenant.current).to eq db_name - expect(User.find_by(name: db_name).name).to eq(db_name) + expect(Apartment::Tenant.current).to(eq(db_name)) + expect(User.find_by!(name: db_name).name).to(eq(db_name)) end end end diff --git a/spec/integration/query_caching_spec.rb b/spec/integration/query_caching_spec.rb index e7a4ebdf..5345a17a 100644 --- a/spec/integration/query_caching_spec.rb +++ b/spec/integration/query_caching_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'query caching' do - describe 'when use_schemas = true' do + describe 'when use_schemas = true', database: :postgresql do let(:db_names) { [db1, db2] } before do @@ -14,10 +14,11 @@ end Apartment::Tenant.reload!(config) + Apartment::Tenant.init db_names.each do |db_name| Apartment::Tenant.create(db_name) - Company.create database: db_name + Company.create(database: db_name) end end @@ -25,25 +26,31 @@ db_names.each { |db| Apartment::Tenant.drop(db) } Apartment::Tenant.reset Company.delete_all + + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end end it 'clears the ActiveRecord::QueryCache after switching databases' do db_names.each do |db_name| - Apartment::Tenant.switch! db_name - User.create! name: db_name + Apartment::Tenant.switch!(db_name) + User.create!(name: db_name) end ActiveRecord::Base.connection.enable_query_cache! - Apartment::Tenant.switch! db_names.first - expect(User.find_by(name: db_names.first).name).to eq(db_names.first) + Apartment::Tenant.switch!(db_names.first) + expect(User.find_by(name: db_names.first).name).to(eq(db_names.first)) - Apartment::Tenant.switch! db_names.last - expect(User.find_by(name: db_names.first)).to be_nil + Apartment::Tenant.switch!(db_names.last) + expect(User.find_by(name: db_names.first)).to(be_nil) end end - describe 'when use_schemas = false' do + describe 'when use_schemas = false', database: :mysql do let(:db_name) { db1 } before do @@ -54,9 +61,10 @@ end Apartment::Tenant.reload!(config) + Apartment::Tenant.init Apartment::Tenant.create(db_name) - Company.create database: db_name + Company.create(database: db_name) end after do @@ -64,18 +72,24 @@ Apartment::Tenant.drop(db_name) Company.delete_all + + # Apartment::Tenant.init creates per model connection. + # Remove the connection after testing not to unintentionally keep the connection across tests. + Apartment.excluded_models.each do |excluded_model| + excluded_model.constantize.remove_connection + end end it 'configuration value is kept after switching databases' do ActiveRecord::Base.connection.enable_query_cache! - Apartment::Tenant.switch! db_name - expect(Apartment.connection.query_cache_enabled).to be true + Apartment::Tenant.switch!(db_name) + expect(Apartment.connection.query_cache_enabled).to(be(true)) ActiveRecord::Base.connection.disable_query_cache! - Apartment::Tenant.switch! db_name - expect(Apartment.connection.query_cache_enabled).to be false + Apartment::Tenant.switch!(db_name) + expect(Apartment.connection.query_cache_enabled).to(be(false)) end end end diff --git a/spec/integration/use_within_an_engine_spec.rb b/spec/integration/use_within_an_engine_spec.rb index f3269ba4..c7eca216 100644 --- a/spec/integration/use_within_an_engine_spec.rb +++ b/spec/integration/use_within_an_engine_spec.rb @@ -11,17 +11,17 @@ end it 'sucessfully runs rake db:migrate in the engine root' do - expect { Rake::Task['db:migrate'].invoke }.not_to raise_error + expect { Rake::Task['db:migrate'].invoke }.not_to(raise_error) end it 'sucessfully runs rake app:db:migrate in the engine root' do - expect { Rake::Task['app:db:migrate'].invoke }.not_to raise_error + expect { Rake::Task['app:db:migrate'].invoke }.not_to(raise_error) end context 'when Apartment.db_migrate_tenants is false' do - it 'should not enhance tasks' do + it 'does not enhance tasks' do Apartment.db_migrate_tenants = false - expect(Apartment::RakeTaskEnhancer).not_to receive(:enhance_task).with('db:migrate') + expect(Apartment::RakeTaskEnhancer).not_to(receive(:enhance_task).with('db:migrate')) Rake::Task['db:migrate'].invoke end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c3511ef0..11a0eb80 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -45,7 +45,6 @@ config.include(Apartment::Spec::Setup) # Somewhat brutal hack so that rails 4 postgres extensions don't modify this file - # rubocop:disable RSpec/BeforeAfterAll config.after(:all) do `git checkout -- spec/dummy/db/schema.rb` end diff --git a/spec/support/contexts.rb b/spec/support/contexts.rb index 6a2c2c3b..f2a2a19a 100644 --- a/spec/support/contexts.rb +++ b/spec/support/contexts.rb @@ -2,7 +2,7 @@ # Some shared contexts for specs -shared_context 'with default schema', default_tenant: true do +shared_context 'with default schema', :default_tenant do let(:default_tenant) { Apartment::Test.next_db } before do @@ -20,7 +20,7 @@ end # Some default setup for elevator specs -shared_context 'elevators', elevator: true do +shared_context 'elevators', :elevator do let(:company1) { mock_model(Company, database: db1).as_null_object } let(:company2) { mock_model(Company, database: db2).as_null_object } @@ -41,7 +41,7 @@ end end -shared_context 'persistent_schemas', persistent_schemas: true do +shared_context 'persistent_schemas', :persistent_schemas do let(:persistent_schemas) { %w[hstore postgis] } before do diff --git a/spec/support/setup.rb b/spec/support/setup.rb index dfc2ded0..20180dd8 100644 --- a/spec/support/setup.rb +++ b/spec/support/setup.rb @@ -13,7 +13,7 @@ def self.included(base) # This around ensures that we run these hooks before and after # any before/after hooks defined in individual tests # Otherwise these actually get run after test defined hooks - around(:each) do |example| + around do |example| def config db = RSpec.current_example.metadata.fetch(:database, :postgresql) @@ -22,7 +22,7 @@ def config # before Apartment::Tenant.reload!(config) - ActiveRecord::Base.establish_connection config + ActiveRecord::Base.establish_connection(config) example.run diff --git a/spec/tasks/apartment_rake_spec.rb b/spec/tasks/apartment_rake_spec.rb index e75c8c92..93a881f9 100644 --- a/spec/tasks/apartment_rake_spec.rb +++ b/spec/tasks/apartment_rake_spec.rb @@ -11,6 +11,7 @@ Rake.application = @rake load 'tasks/apartment.rake' # stub out rails tasks + Rake::Task.define_task('environment') Rake::Task.define_task('db:migrate') Rake::Task.define_task('db:seed') Rake::Task.define_task('db:rollback') @@ -35,16 +36,16 @@ let(:tenant_count) { tenant_names.length } before do - allow(Apartment).to receive(:tenant_names).and_return tenant_names + allow(Apartment).to(receive(:tenant_names).and_return(tenant_names)) end describe 'apartment:migrate' do before do - allow(ActiveRecord::Migrator).to receive(:migrate) # don't care about this + allow(ActiveRecord::Migrator).to(receive(:migrate)) # don't care about this end - it 'should migrate public and all multi-tenant dbs' do - expect(Apartment::Migrator).to receive(:migrate).exactly(tenant_count).times + it 'migrates public and all multi-tenant dbs' do + expect(Apartment::Migrator).to(receive(:migrate).exactly(tenant_count).times) @rake['apartment:migrate'].invoke end end @@ -58,7 +59,7 @@ it 'requires a version to migrate to' do expect do @rake['apartment:migrate:up'].invoke - end.to raise_error('VERSION is required') + end.to(raise_error('VERSION is required')) end end @@ -68,7 +69,7 @@ end it 'migrates up to a specific version' do - expect(Apartment::Migrator).to receive(:run).with(:up, anything, version.to_i).exactly(tenant_count).times + expect(Apartment::Migrator).to(receive(:run).with(:up, anything, version.to_i).exactly(tenant_count).times) @rake['apartment:migrate:up'].invoke end end @@ -83,7 +84,7 @@ it 'requires a version to migrate to' do expect do @rake['apartment:migrate:down'].invoke - end.to raise_error('VERSION is required') + end.to(raise_error('VERSION is required')) end end @@ -93,7 +94,7 @@ end it 'migrates up to a specific version' do - expect(Apartment::Migrator).to receive(:run).with(:down, anything, version.to_i).exactly(tenant_count).times + expect(Apartment::Migrator).to(receive(:run).with(:down, anything, version.to_i).exactly(tenant_count).times) @rake['apartment:migrate:down'].invoke end end @@ -102,21 +103,21 @@ describe 'apartment:rollback' do let(:step) { '3' } - it 'should rollback dbs' do - expect(Apartment::Migrator).to receive(:rollback).exactly(tenant_count).times + it 'rollbacks dbs' do + expect(Apartment::Migrator).to(receive(:rollback).exactly(tenant_count).times) @rake['apartment:rollback'].invoke end - it 'should rollback dbs STEP amt' do - expect(Apartment::Migrator).to receive(:rollback).with(anything, step.to_i).exactly(tenant_count).times + it 'rollbacks dbs STEP amt' do + expect(Apartment::Migrator).to(receive(:rollback).with(anything, step.to_i).exactly(tenant_count).times) ENV['STEP'] = step @rake['apartment:rollback'].invoke end end describe 'apartment:drop' do - it 'should migrate public and all multi-tenant dbs' do - expect(Apartment::Tenant).to receive(:drop).exactly(tenant_count).times + it 'migrates public and all multi-tenant dbs' do + expect(Apartment::Tenant).to(receive(:drop).exactly(tenant_count).times) @rake['apartment:drop'].invoke end end diff --git a/spec/tenant_spec.rb b/spec/tenant_spec.rb index 7292588e..5285e9fb 100644 --- a/spec/tenant_spec.rb +++ b/spec/tenant_spec.rb @@ -4,14 +4,17 @@ describe Apartment::Tenant do context 'using mysql', database: :mysql do - before { subject.reload!(config) } + before do + Apartment.use_schemas = false + subject.reload!(config) + end describe '#adapter' do - it 'should load mysql adapter' do + it 'loads mysql adapter' do if defined?(JRUBY_VERSION) - expect(subject.adapter).to be_a(Apartment::Adapters::JDBCMysqlAdapter) + expect(subject.adapter).to(be_a(Apartment::Adapters::JDBCMysqlAdapter)) else - expect(subject.adapter).to be_a(Apartment::Adapters::Mysql2Adapter) + expect(subject.adapter).to(be_a(Apartment::Adapters::Mysql2Adapter)) end end end @@ -29,13 +32,13 @@ end after do - subject.drop 'db_with_prefix' + subject.drop('db_with_prefix') rescue StandardError => _e nil end - it 'should create a new database' do - subject.create 'db_with_prefix' + it 'creates a new database' do + subject.create('db_with_prefix') end end end @@ -48,32 +51,32 @@ end describe '#adapter' do - it 'should load postgresql adapter' do + it 'loads postgresql adapter' do if defined?(JRUBY_VERSION) - expect(subject.adapter).to be_a(Apartment::Adapters::JDBCPostgresqlSchemaAdapter) + expect(subject.adapter).to(be_a(Apartment::Adapters::JDBCPostgresqlSchemaAdapter)) else - expect(subject.adapter).to be_a(Apartment::Adapters::PostgresqlSchemaAdapter) + expect(subject.adapter).to(be_a(Apartment::Adapters::PostgresqlSchemaAdapter)) end end it 'raises exception with invalid adapter specified' do - subject.reload!(config.merge(adapter: 'unknown')) + subject.reload!((config || Apartment.connection_config).merge(adapter: 'unknown')) expect do - Apartment::Tenant.adapter - end.to raise_error(RuntimeError) + described_class.adapter + end.to(raise_error(RuntimeError)) end context 'threadsafety' do - before { subject.create db1 } + before { subject.create(db1) } - after { subject.drop db1 } + after { subject.drop(db1) } it 'has a threadsafe adapter' do subject.switch!(db1) - thread = Thread.new { expect(subject.current).to eq(subject.adapter.default_tenant) } + thread = Thread.new { expect(subject.current).to(eq(subject.adapter.default_tenant)) } thread.join - expect(subject.current).to eq(db1) + expect(subject.current).to(eq(db1)) end end end @@ -86,15 +89,15 @@ config.use_schemas = true config.seed_after_create = true end - subject.create db1 + subject.create(db1) end - after { subject.drop db1 } + after { subject.drop(db1) } describe '#create' do - it 'should seed data' do - subject.switch! db1 - expect(User.count).to be > 0 + it 'seeds data' do + subject.switch!(db1) + expect(User.count).to(be > 0) end end @@ -102,22 +105,22 @@ let(:x) { rand(3) } context 'creating models' do - before { subject.create db2 } + before { subject.create(db2) } - after { subject.drop db2 } + after { subject.drop(db2) } - it 'should create a model instance in the current schema' do - subject.switch! db2 + it 'creates a model instance in the current schema' do + subject.switch!(db2) db2_count = User.count + x.times { User.create } - subject.switch! db1 + subject.switch!(db1) db_count = User.count + x.times { User.create } - subject.switch! db2 - expect(User.count).to eq(db2_count) + subject.switch!(db2) + expect(User.count).to(eq(db2_count)) - subject.switch! db1 - expect(User.count).to eq(db_count) + subject.switch!(db1) + expect(User.count).to(eq(db_count)) end end @@ -137,15 +140,15 @@ end end - it 'should create excluded models in public schema' do + it 'creates excluded models in public schema' do subject.reset # ensure we're on public schema count = Company.count + x.times { Company.create } - subject.switch! db1 + subject.switch!(db1) x.times { Company.create } - expect(Company.count).to eq(count + x) + expect(Company.count).to(eq(count + x)) subject.reset - expect(Company.count).to eq(count + x) + expect(Company.count).to(eq(count + x)) end end end @@ -160,23 +163,23 @@ end end - after { subject.drop db1 } + after { subject.drop(db1) } - it 'should seed from default path' do - subject.create db1 - subject.switch! db1 - expect(User.count).to eq(3) - expect(User.first.name).to eq('Some User 0') + it 'seeds from default path' do + subject.create(db1) + subject.switch!(db1) + expect(User.count).to(eq(3)) + expect(User.first.name).to(eq('Some User 0')) end - it 'should seed from custom path' do + it 'seeds from custom path' do Apartment.configure do |config| config.seed_data_file = Rails.root.join('db/seeds/import.rb') end - subject.create db1 - subject.switch! db1 - expect(User.count).to eq(6) - expect(User.first.name).to eq('Different User 0') + subject.create(db1) + subject.switch!(db1) + expect(User.count).to(eq(6)) + expect(User.first.name).to(eq('Different User 0')) end end end diff --git a/spec/unit/config_spec.rb b/spec/unit/config_spec.rb index c15514da..1aaf582a 100644 --- a/spec/unit/config_spec.rb +++ b/spec/unit/config_spec.rb @@ -8,15 +8,15 @@ let(:seed_data_file_path) { Rails.root.join('db/seeds/import.rb') } def tenant_names_from_array(names) - names.each_with_object({}) do |tenant, hash| - hash[tenant] = Apartment.connection_config + names.index_with do |_tenant| + Apartment.connection_config end.with_indifferent_access end it 'yields the Apartment object' do described_class.configure do |config| config.excluded_models = [] - expect(config).to eq(described_class) + expect(config).to(eq(described_class)) end end @@ -24,7 +24,7 @@ def tenant_names_from_array(names) described_class.configure do |config| config.excluded_models = excluded_models end - expect(described_class.excluded_models).to eq(excluded_models) + expect(described_class.excluded_models).to(eq(excluded_models)) end it 'sets use_schemas' do @@ -32,14 +32,14 @@ def tenant_names_from_array(names) config.excluded_models = [] config.use_schemas = false end - expect(described_class.use_schemas).to be false + expect(described_class.use_schemas).to(be(false)) end it 'sets seed_data_file' do described_class.configure do |config| config.seed_data_file = seed_data_file_path end - expect(described_class.seed_data_file).to eq(seed_data_file_path) + expect(described_class.seed_data_file).to(eq(seed_data_file_path)) end it 'sets seed_after_create' do @@ -47,21 +47,21 @@ def tenant_names_from_array(names) config.excluded_models = [] config.seed_after_create = true end - expect(described_class.seed_after_create).to be true + expect(described_class.seed_after_create).to(be(true)) end it 'sets tenant_presence_check' do described_class.configure do |config| config.tenant_presence_check = true end - expect(described_class.tenant_presence_check).to be true + expect(described_class.tenant_presence_check).to(be(true)) end it 'sets active_record_log' do described_class.configure do |config| config.active_record_log = true end - expect(described_class.active_record_log).to be true + expect(described_class.active_record_log).to(be(true)) end context 'when databases' do @@ -77,11 +77,11 @@ def tenant_names_from_array(names) let(:tenant_names) { %w[users companies] } it 'returns object if it doesnt respond_to call' do - expect(described_class.tenant_names).to eq(tenant_names_from_array(tenant_names).keys) + expect(described_class.tenant_names).to(eq(tenant_names_from_array(tenant_names).keys)) end it 'sets tenants_with_config' do - expect(described_class.tenants_with_config).to eq(tenant_names_from_array(tenant_names)) + expect(described_class.tenants_with_config).to(eq(tenant_names_from_array(tenant_names))) end end @@ -89,11 +89,11 @@ def tenant_names_from_array(names) let(:tenant_names) { -> { %w[users companies] } } it 'returns object if it doesnt respond_to call' do - expect(described_class.tenant_names).to eq(tenant_names_from_array(tenant_names.call).keys) + expect(described_class.tenant_names).to(eq(tenant_names_from_array(tenant_names.call).keys)) end it 'sets tenants_with_config' do - expect(described_class.tenants_with_config).to eq(tenant_names_from_array(tenant_names.call)) + expect(described_class.tenants_with_config).to(eq(tenant_names_from_array(tenant_names.call))) end end @@ -101,11 +101,11 @@ def tenant_names_from_array(names) let(:tenant_names) { { users: users_conf_hash }.with_indifferent_access } it 'returns object if it doesnt respond_to call' do - expect(described_class.tenant_names).to eq(tenant_names.keys) + expect(described_class.tenant_names).to(eq(tenant_names.keys)) end it 'sets tenants_with_config' do - expect(described_class.tenants_with_config).to eq(tenant_names) + expect(described_class.tenants_with_config).to(eq(tenant_names)) end end @@ -113,11 +113,11 @@ def tenant_names_from_array(names) let(:tenant_names) { -> { { users: users_conf_hash }.with_indifferent_access } } it 'returns object if it doesnt respond_to call' do - expect(described_class.tenant_names).to eq(tenant_names.call.keys) + expect(described_class.tenant_names).to(eq(tenant_names.call.keys)) end it 'sets tenants_with_config' do - expect(described_class.tenants_with_config).to eq(tenant_names.call) + expect(described_class.tenants_with_config).to(eq(tenant_names.call)) end end end diff --git a/spec/unit/elevators/domain_spec.rb b/spec/unit/elevators/domain_spec.rb index 520b315c..10714282 100644 --- a/spec/unit/elevators/domain_spec.rb +++ b/spec/unit/elevators/domain_spec.rb @@ -9,23 +9,23 @@ describe '#parse_tenant_name' do it 'parses the host for a domain name' do request = ActionDispatch::Request.new('HTTP_HOST' => 'example.com') - expect(elevator.parse_tenant_name(request)).to eq('example') + expect(elevator.parse_tenant_name(request)).to(eq('example')) end it 'ignores a www prefix and domain suffix' do request = ActionDispatch::Request.new('HTTP_HOST' => 'www.example.bc.ca') - expect(elevator.parse_tenant_name(request)).to eq('example') + expect(elevator.parse_tenant_name(request)).to(eq('example')) end it 'returns nil if there is no host' do request = ActionDispatch::Request.new('HTTP_HOST' => '') - expect(elevator.parse_tenant_name(request)).to be_nil + expect(elevator.parse_tenant_name(request)).to(be_nil) end end describe '#call' do it 'switches to the proper tenant' do - expect(Apartment::Tenant).to receive(:switch).with('example') + expect(Apartment::Tenant).to(receive(:switch).with('example')) elevator.call('HTTP_HOST' => 'www.example.com') end diff --git a/spec/unit/elevators/first_subdomain_spec.rb b/spec/unit/elevators/first_subdomain_spec.rb index fc36a109..e0f58910 100644 --- a/spec/unit/elevators/first_subdomain_spec.rb +++ b/spec/unit/elevators/first_subdomain_spec.rb @@ -12,19 +12,19 @@ context 'when one subdomain' do let(:subdomain) { 'test' } - it { is_expected.to eq('test') } + it { is_expected.to(eq('test')) } end context 'when nested subdomains' do let(:subdomain) { 'test1.test2' } - it { is_expected.to eq('test1') } + it { is_expected.to(eq('test1')) } end context 'when no subdomain' do let(:subdomain) { nil } - it { is_expected.to eq(nil) } + it { is_expected.to(be_nil) } end end end diff --git a/spec/unit/elevators/generic_spec.rb b/spec/unit/elevators/generic_spec.rb index f4112e78..93409138 100644 --- a/spec/unit/elevators/generic_spec.rb +++ b/spec/unit/elevators/generic_spec.rb @@ -18,7 +18,7 @@ def parse_tenant_name(*) it 'calls the processor if given' do elevator = described_class.new(proc {}, proc { 'tenant1' }) - expect(Apartment::Tenant).to receive(:switch).with('tenant1') + expect(Apartment::Tenant).to(receive(:switch).with('tenant1')) elevator.call('HTTP_HOST' => 'foo.bar.com') end @@ -26,13 +26,13 @@ def parse_tenant_name(*) it 'raises if parse_tenant_name not implemented' do expect do elevator.call('HTTP_HOST' => 'foo.bar.com') - end.to raise_error(RuntimeError) + end.to(raise_error(RuntimeError)) end it 'switches to the parsed db_name' do elevator = MyElevator.new(proc {}) - expect(Apartment::Tenant).to receive(:switch).with('tenant2') + expect(Apartment::Tenant).to(receive(:switch).with('tenant2')) elevator.call('HTTP_HOST' => 'foo.bar.com') end @@ -40,7 +40,7 @@ def parse_tenant_name(*) it 'calls the block implementation of `switch`' do elevator = MyElevator.new(proc {}, proc { 'tenant2' }) - expect(Apartment::Tenant).to receive(:switch).with('tenant2').and_yield + expect(Apartment::Tenant).to(receive(:switch).with('tenant2').and_yield) elevator.call('HTTP_HOST' => 'foo.bar.com') end @@ -48,8 +48,8 @@ def parse_tenant_name(*) app = proc {} elevator = MyElevator.new(app, proc {}) - expect(Apartment::Tenant).not_to receive(:switch) - expect(app).to receive :call + expect(Apartment::Tenant).not_to(receive(:switch)) + expect(app).to(receive(:call)) elevator.call('HTTP_HOST' => 'foo.bar.com') end diff --git a/spec/unit/elevators/host_hash_spec.rb b/spec/unit/elevators/host_hash_spec.rb index 6fc2f72b..6449d6eb 100644 --- a/spec/unit/elevators/host_hash_spec.rb +++ b/spec/unit/elevators/host_hash_spec.rb @@ -4,28 +4,28 @@ require 'apartment/elevators/host_hash' describe Apartment::Elevators::HostHash do - subject(:elevator) { Apartment::Elevators::HostHash.new(proc {}, 'example.com' => 'example_tenant') } + subject(:elevator) { described_class.new(proc {}, 'example.com' => 'example_tenant') } describe '#parse_tenant_name' do it 'parses the host for a domain name' do request = ActionDispatch::Request.new('HTTP_HOST' => 'example.com') - expect(elevator.parse_tenant_name(request)).to eq('example_tenant') + expect(elevator.parse_tenant_name(request)).to(eq('example_tenant')) end it 'raises TenantNotFound exception if there is no host' do request = ActionDispatch::Request.new('HTTP_HOST' => '') - expect { elevator.parse_tenant_name(request) }.to raise_error(Apartment::TenantNotFound) + expect { elevator.parse_tenant_name(request) }.to(raise_error(Apartment::TenantNotFound)) end it 'raises TenantNotFound exception if there is no database associated to current host' do request = ActionDispatch::Request.new('HTTP_HOST' => 'example2.com') - expect { elevator.parse_tenant_name(request) }.to raise_error(Apartment::TenantNotFound) + expect { elevator.parse_tenant_name(request) }.to(raise_error(Apartment::TenantNotFound)) end end describe '#call' do it 'switches to the proper tenant' do - expect(Apartment::Tenant).to receive(:switch).with('example_tenant') + expect(Apartment::Tenant).to(receive(:switch).with('example_tenant')) elevator.call('HTTP_HOST' => 'example.com') end diff --git a/spec/unit/elevators/host_spec.rb b/spec/unit/elevators/host_spec.rb index f8d77949..fc414e69 100644 --- a/spec/unit/elevators/host_spec.rb +++ b/spec/unit/elevators/host_spec.rb @@ -9,39 +9,39 @@ describe '#parse_tenant_name' do it 'returns nil when no host' do request = ActionDispatch::Request.new('HTTP_HOST' => '') - expect(elevator.parse_tenant_name(request)).to be_nil + expect(elevator.parse_tenant_name(request)).to(be_nil) end context 'when assuming no ignored_first_subdomains' do - before { allow(described_class).to receive(:ignored_first_subdomains).and_return([]) } + before { allow(described_class).to(receive(:ignored_first_subdomains).and_return([])) } context 'with 3 parts' do it 'returns the whole host' do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('foo.bar.com') + expect(elevator.parse_tenant_name(request)).to(eq('foo.bar.com')) end end context 'with 6 parts' do it 'returns the whole host' do request = ActionDispatch::Request.new('HTTP_HOST' => 'one.two.three.foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('one.two.three.foo.bar.com') + expect(elevator.parse_tenant_name(request)).to(eq('one.two.three.foo.bar.com')) end end end context 'when assuming ignored_first_subdomains is set' do - before { allow(described_class).to receive(:ignored_first_subdomains).and_return(%w[www foo]) } + before { allow(described_class).to(receive(:ignored_first_subdomains).and_return(%w[www foo])) } context 'with 3 parts' do it 'returns host without www' do request = ActionDispatch::Request.new('HTTP_HOST' => 'www.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('bar.com') + expect(elevator.parse_tenant_name(request)).to(eq('bar.com')) end it 'returns host without foo' do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('bar.com') + expect(elevator.parse_tenant_name(request)).to(eq('bar.com')) end end @@ -51,7 +51,7 @@ it 'returns host without www' do request = ActionDispatch::Request.new('HTTP_HOST' => http_host) - expect(elevator.parse_tenant_name(request)).to eq('one.two.three.foo.bar.com') + expect(elevator.parse_tenant_name(request)).to(eq('one.two.three.foo.bar.com')) end end @@ -60,7 +60,7 @@ it 'returns host without matching subdomain' do request = ActionDispatch::Request.new('HTTP_HOST' => http_host) - expect(elevator.parse_tenant_name(request)).to eq('one.two.three.bar.com') + expect(elevator.parse_tenant_name(request)).to(eq('one.two.three.bar.com')) end end end @@ -69,28 +69,28 @@ context 'when assuming localhost' do it 'returns localhost' do request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost') - expect(elevator.parse_tenant_name(request)).to eq('localhost') + expect(elevator.parse_tenant_name(request)).to(eq('localhost')) end end context 'when assuming ip address' do it 'returns the ip address' do request = ActionDispatch::Request.new('HTTP_HOST' => '127.0.0.1') - expect(elevator.parse_tenant_name(request)).to eq('127.0.0.1') + expect(elevator.parse_tenant_name(request)).to(eq('127.0.0.1')) end end end describe '#call' do it 'switches to the proper tenant' do - allow(described_class).to receive(:ignored_first_subdomains).and_return([]) - expect(Apartment::Tenant).to receive(:switch).with('foo.bar.com') + allow(described_class).to(receive(:ignored_first_subdomains).and_return([])) + expect(Apartment::Tenant).to(receive(:switch).with('foo.bar.com')) elevator.call('HTTP_HOST' => 'foo.bar.com') end it 'ignores ignored_first_subdomains' do - allow(described_class).to receive(:ignored_first_subdomains).and_return(%w[foo]) - expect(Apartment::Tenant).to receive(:switch).with('bar.com') + allow(described_class).to(receive(:ignored_first_subdomains).and_return(%w[foo])) + expect(Apartment::Tenant).to(receive(:switch).with('bar.com')) elevator.call('HTTP_HOST' => 'foo.bar.com') end end diff --git a/spec/unit/elevators/subdomain_spec.rb b/spec/unit/elevators/subdomain_spec.rb index b1cd8f4b..aebbf037 100644 --- a/spec/unit/elevators/subdomain_spec.rb +++ b/spec/unit/elevators/subdomain_spec.rb @@ -10,64 +10,64 @@ context 'when assuming one tld' do it 'parses subdomain' do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('foo') + expect(elevator.parse_tenant_name(request)).to(eq('foo')) end it 'returns nil when no subdomain' do request = ActionDispatch::Request.new('HTTP_HOST' => 'bar.com') - expect(elevator.parse_tenant_name(request)).to be_nil + expect(elevator.parse_tenant_name(request)).to(be_nil) end end context 'when assuming two tlds' do it 'parses subdomain in the third level domain' do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.co.uk') - expect(elevator.parse_tenant_name(request)).to eq('foo') + expect(elevator.parse_tenant_name(request)).to(eq('foo')) end it 'returns nil when no subdomain in the third level domain' do request = ActionDispatch::Request.new('HTTP_HOST' => 'bar.co.uk') - expect(elevator.parse_tenant_name(request)).to be_nil + expect(elevator.parse_tenant_name(request)).to(be_nil) end end context 'when assuming two subdomains' do it 'parses two subdomains in the two level domain' do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.xyz.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('foo') + expect(elevator.parse_tenant_name(request)).to(eq('foo')) end it 'parses two subdomains in the third level domain' do request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.xyz.bar.co.uk') - expect(elevator.parse_tenant_name(request)).to eq('foo') + expect(elevator.parse_tenant_name(request)).to(eq('foo')) end end context 'when assuming localhost' do it 'returns nil for localhost' do request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost') - expect(elevator.parse_tenant_name(request)).to be_nil + expect(elevator.parse_tenant_name(request)).to(be_nil) end end context 'when assuming ip address' do it 'returns nil for an ip address' do request = ActionDispatch::Request.new('HTTP_HOST' => '127.0.0.1') - expect(elevator.parse_tenant_name(request)).to be_nil + expect(elevator.parse_tenant_name(request)).to(be_nil) end end end describe '#call' do it 'switches to the proper tenant' do - expect(Apartment::Tenant).to receive(:switch).with('tenant1') + expect(Apartment::Tenant).to(receive(:switch).with('tenant1')) elevator.call('HTTP_HOST' => 'tenant1.example.com') end it 'ignores excluded subdomains' do described_class.excluded_subdomains = %w[foo] - expect(Apartment::Tenant).not_to receive(:switch) + expect(Apartment::Tenant).not_to(receive(:switch)) elevator.call('HTTP_HOST' => 'foo.bar.com') diff --git a/spec/unit/migrator_spec.rb b/spec/unit/migrator_spec.rb index 9dc3f19e..56e63e88 100644 --- a/spec/unit/migrator_spec.rb +++ b/spec/unit/migrator_spec.rb @@ -7,33 +7,33 @@ let(:tenant) { Apartment::Test.next_db } # Don't need a real switch here, just testing behaviour - before { allow(Apartment::Tenant.adapter).to receive(:connect_to_new) } + before { allow(Apartment::Tenant.adapter).to(receive(:connect_to_new)) } context 'with ActiveRecord above or equal to 6.1.0' do describe '::migrate' do it 'switches and migrates' do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect_any_instance_of(ActiveRecord::MigrationContext).to receive(:migrate) + expect(Apartment::Tenant).to(receive(:switch).with(tenant).and_call_original) + expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:migrate)) - Apartment::Migrator.migrate(tenant) + described_class.migrate(tenant) end end describe '::run' do it 'switches and runs' do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect_any_instance_of(ActiveRecord::MigrationContext).to receive(:run).with(:up, 1234) + expect(Apartment::Tenant).to(receive(:switch).with(tenant).and_call_original) + expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:run).with(:up, 1234)) - Apartment::Migrator.run(:up, tenant, 1234) + described_class.run(:up, tenant, 1234) end end describe '::rollback' do it 'switches and rolls back' do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect_any_instance_of(ActiveRecord::MigrationContext).to receive(:rollback).with(2) + expect(Apartment::Tenant).to(receive(:switch).with(tenant).and_call_original) + expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:rollback).with(2)) - Apartment::Migrator.rollback(tenant, 2) + described_class.rollback(tenant, 2) end end end