diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 2f3e3c8b..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,78 +0,0 @@ -version: 2.1 - -orbs: - rubocop: hanachin/rubocop@0.0.6 - -jobs: - build: - docker: - - image: circleci/<< parameters.ruby_version >> - - image: circleci/postgres:9.6.2-alpine - - image: circleci/mysql:5.7 - environment: - MYSQL_ALLOW_EMPTY_PASSWORD: "yes" - parameters: - ruby_version: - type: string - gemfile: - type: string - environment: - BUNDLE_GEMFILE: << parameters.gemfile >> - steps: - - checkout - # Restore Cached Dependencies - # - restore_cache: - # keys: - # - gem-cache-v1-{{ arch }}-{{ .Branch }}-{{ checksum "<< parameters.gemfile >>.lock" }} - # - gem-cache-v1-{{ arch }}-{{ .Branch }} - # - gem-cache-v1 - - - run: bundle install --path vendor/bundle - - # - save_cache: - # key: gem-cache-v1-{{ arch }}-{{ .Branch }}-{{ checksum "<< parameters.gemfile >>.lock" }} - # paths: - # - vendor/bundle - - - run: - name: Install postgres client - command: sudo apt install -y postgresql-client - - - run: - name: Install mysql client - command: sudo apt install -y default-mysql-client - - - run: - name: Configure config database.yml - command: bundle exec rake db:copy_credentials - - - run: - name: wait for postgresql - command: dockerize -wait tcp://localhost:5432 -timeout 1m - - - run: - name: wait for mysql - command: dockerize -wait tcp://localhost:3306 -timeout 1m - - - run: - name: Database Setup - command: | - bundle exec rake db:test:prepare - - - run: - name: Run tests - command: bundle exec rspec - -workflows: - tests: - jobs: - - build: - matrix: - parameters: - ruby_version: ["ruby:2.6-buster", "ruby:2.7-buster"] - gemfile: ["gemfiles/rails_5_2.gemfile", "gemfiles/rails_6_0.gemfile", "gemfiles/rails_6_1.gemfile"] - - rubocop: - jobs: - - rubocop/rubocop: - version: 0.88.0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..583d1403 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,18 @@ +{ + "enabledPlugins": { + "ruby-lsp@claude-plugins-official": true, + "superpowers@claude-plugins-official": true, + "context7@claude-plugins-official": true, + "code-review@claude-plugins-official": true, + "commit-commands@claude-plugins-official": true, + "pr-review-toolkit@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "feature-dev@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "sentry@claude-plugins-official": true, + "github@claude-plugins-official": true, + "security-guidance@claude-plugins-official": true, + "firecrawl@claude-plugins-official": true, + "skill-creator@claude-plugins-official": true + } +} diff --git a/.claude/skills/manage-worktree/SKILL.md b/.claude/skills/manage-worktree/SKILL.md new file mode 100644 index 00000000..a64504f9 --- /dev/null +++ b/.claude/skills/manage-worktree/SKILL.md @@ -0,0 +1,119 @@ +--- +name: manage-worktree +description: > + Create, move, or remove a git worktree. + Use when the user asks to create a worktree, start a new branch in a worktree, + move a branch to a worktree, work on something in parallel, remove/delete a worktree, + or says "/manage-worktree ". Handles stashing, branch management, setup, and cleanup. +argument-hint: [branch-name] +allowed-tools: Bash(git worktree *), Bash(bin/dev/setup-worktree *), Bash(bin/dev/remove-worktree *), Bash(git branch *), Bash(git stash *), Bash(git checkout *), Bash(git switch *) +--- + +# Manage Worktree + +## Path Resolution + +**Always resolve the worktree base from the main repo** — relative paths like `../apartment-worktrees/` +break when invoked from inside an existing worktree. + +```bash +MAIN_REPO=$(git worktree list --porcelain | head -1 | sed 's/^worktree //') +WORKTREE_BASE="$(dirname "$MAIN_REPO")/apartment-worktrees" +``` + +Use `$WORKTREE_BASE/` for all worktree paths in the steps below. + +## Conventions + +- Path: `$WORKTREE_BASE/` (sibling to main repo) +- Branch: `/` (e.g., `man/v4-foundation`, `jb/fix-auth`) +- Base: current branch (confirm if it's a feature branch rather than `development`) +- **Always** run `bin/dev/setup-worktree` after `git worktree add` +- Remove with: `bin/dev/remove-worktree --delete-branch` + +## Mode Detection + +Determine the mode from the argument and user intent: + +1. **Remove mode** — if the user says "remove", "delete", "clean up", or "tear down" a worktree +2. **Move mode** — if `$ARGUMENTS` matches an existing local branch (`git branch --list "$ARGUMENTS"`), or the user says "move this branch to a worktree" +3. **Create mode** — otherwise (new branch + worktree) + +## Create Mode (new branch) + +1. **Detect developer prefix** from remote branches: + ```bash + git branch -r | sed 's|^ *origin/||' | grep -E '^[a-z]{2,4}/' | cut -d/ -f1 | sort | uniq -c | sort -rn + ``` + Known prefixes: `man`. Exclude automated prefixes (`seer/`, `fix/`, `feat/`, `docs/`). Ask if unclear. + +2. **Stash** uncommitted changes (if dirty): `git stash push -u -m "pre-worktree: $ARGUMENTS"` + +3. **Get base branch**: `git branch --show-current` + +4. **Create worktree**: + ```bash + mkdir -p "$WORKTREE_BASE" + git worktree add "$WORKTREE_BASE/$ARGUMENTS" -b /$ARGUMENTS + ``` + +5. **Run setup** (copies `.claude`, `.bundle`, `.vscode`, sets Peacock color): + ```bash + bin/dev/setup-worktree "$WORKTREE_BASE/$ARGUMENTS" + ``` + +6. **Pop stash** in the **original** worktree (not the new one) if stashed in step 2. + +7. **Report**: worktree path, branch name, base branch, any issues. + +## Move Mode (existing branch → worktree) + +Moves the current or specified branch out of the main repo into a dedicated worktree. +The main repo switches back to a base branch (`development` by default). + +1. **Identify the branch to move**: `$ARGUMENTS` or `git branch --show-current` + +2. **Derive worktree name** from the branch name (strip the dev prefix): + - `man/v4-foundation` → worktree name: `v4-foundation` + - Already unprefixed → use as-is + +3. **Stash** uncommitted changes (if dirty): `git stash push -u -m "pre-worktree-move: "` + +4. **Determine return branch**: default is `development`. + +5. **Switch main repo** to the return branch: `git switch ` + +6. **Create worktree** with the existing branch (no `-b`): + ```bash + mkdir -p "$WORKTREE_BASE" + git worktree add "$WORKTREE_BASE/" + ``` + +7. **Run setup**: + ```bash + bin/dev/setup-worktree "$WORKTREE_BASE/" + ``` + +8. **Pop stash** in the **new worktree** (that's where the work continues): + ```bash + cd "$WORKTREE_BASE/" && git stash pop + ``` + +9. **Report**: worktree path, branch name, return branch in main repo, any issues. + +## Remove Mode (delete worktree) + +1. **Resolve worktree name** from `$ARGUMENTS` + +2. **Verify it exists**: + ```bash + ls "$WORKTREE_BASE/" 2>/dev/null + ``` + +3. **Run removal**: + ```bash + bin/dev/remove-worktree --delete-branch --confirm + ``` + Omit `--confirm` if the user hasn't explicitly confirmed. + +4. **Report**: confirm removal, branch deletion, any issues. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml deleted file mode 100644 index ecf5c1bf..00000000 --- a/.github/workflows/changelog.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Changelog - -on: - pull_request: - types: [closed] - - release: - types: [published] - - issues: - types: [closed, edited] - -jobs: - generate_changelog: - runs-on: ubuntu-latest - name: Generate changelog for master branch - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - - - name: Generate changelog - uses: charmixer/auto-changelog-action@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Commit files - env: - ACTION_EMAIL: action@github.com - ACTION_USERNAME: GitHub Action - run: | - git config --local user.email "$ACTION_EMAIL" - git config --local user.name "$ACTION_USERNAME" - git add CHANGELOG.md && git commit -m 'Updated CHANGELOG.md' && echo ::set-env name=push::1 || echo "No changes to CHANGELOG.md" - - - name: Push changes - if: env.push == 1 - env: - # CI_USER: ${{ secrets.YOUR_GITHUB_USER }} - CI_TOKEN: ${{ secrets.CHANGELOG_GITHUB_TOKEN }} - run: | - git push "https://$GITHUB_ACTOR:$CI_TOKEN@github.com/$GITHUB_REPOSITORY.git" HEAD:master - - # - name: Push changelog to master - # if: env.push == 1 - # uses: ad-m/github-push-action@master - # with: - # github_token: ${{ secrets.CHANGELOG_GITHUB_TOKEN }} - # branch: master - - # - name: Cherry-pick changelog to development - # if: env.push == 1 - # env: - # ACTION_EMAIL: action@github.com - # ACTION_USERNAME: GitHub Action - # run: | - # git config --local user.email "$ACTION_EMAIL" - # git config --local user.name "$ACTION_USERNAME" - # commit_hash=`git show HEAD | egrep commit\ .+$ | cut -d' ' -f2` - # git checkout development - # git pull - # git cherry-pick $commit_hash - # git push diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e4ff3352 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,223 @@ +name: CI + +on: + push: + branches: [development, main, '*-stable'] + pull_request: + types: [opened, synchronize, reopened] + release: + types: [published] + workflow_dispatch: + inputs: + ruby: + description: 'Ruby version (all, 3.3, 3.4, 4.0)' + required: false + default: 'all' + type: choice + options: ['all', '3.3', '3.4', '4.0'] + rails: + description: 'Rails version (all, 7.2, 8.0, 8.1)' + required: false + default: 'all' + type: choice + options: ['all', '7.2', '8.0', '8.1'] + engine: + description: 'Database engine (all, postgresql, mysql, sqlite)' + required: false + default: 'all' + type: choice + options: ['all', 'postgresql', 'mysql', 'sqlite'] + +jobs: + # ── Unit tests (no database required) ────────────────────────────── + unit: + name: Unit · Ruby ${{ matrix.ruby }} · Rails ${{ matrix.rails }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ${{ github.event_name == 'workflow_dispatch' && inputs.ruby != 'all' && fromJSON(format('["{0}"]', inputs.ruby)) || fromJSON('["3.3","3.4","4.0"]') }} + rails: ${{ github.event_name == 'workflow_dispatch' && inputs.rails != 'all' && fromJSON(format('["{0}"]', inputs.rails)) || fromJSON('["7.2","8.0","8.1"]') }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}_sqlite3.gemfile + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run unit specs + run: bundle exec rspec spec/unit/ --format progress + + # ── PostgreSQL integration tests ─────────────────────────────────── + postgresql: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.engine == 'all' || inputs.engine == 'postgresql' }} + name: PG ${{ matrix.pg }} · Ruby ${{ matrix.ruby }} · Rails ${{ matrix.rails }} + runs-on: ubuntu-latest + needs: unit + strategy: + fail-fast: false + matrix: + ruby: ${{ github.event_name == 'workflow_dispatch' && inputs.ruby != 'all' && fromJSON(format('["{0}"]', inputs.ruby)) || fromJSON('["3.3","3.4","4.0"]') }} + rails: ${{ github.event_name == 'workflow_dispatch' && inputs.rails != 'all' && fromJSON(format('["{0}"]', inputs.rails)) || fromJSON('["7.2","8.0","8.1"]') }} + pg: ['16', '18'] + exclude: + # Reduce matrix: test oldest PG with oldest Rails, newest with newest + - pg: '16' + rails: '8.1' + - pg: '18' + rails: '7.2' + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}_postgresql.gemfile + CI: true + RAILS_ENV: test + DATABASE_ENGINE: postgresql + services: + postgres: + image: postgres:${{ matrix.pg }}-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: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Provision RBAC test roles + run: | + psql -h 127.0.0.1 -U postgres -d postgres --set ON_ERROR_STOP=on <<'SQL' + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'apt_test_db_manager') THEN + CREATE ROLE apt_test_db_manager LOGIN CREATEDB; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'apt_test_app_user') THEN + CREATE ROLE apt_test_app_user LOGIN; + END IF; + END $$; + GRANT apt_test_app_user TO apt_test_db_manager; + SQL + - name: Run v4 integration tests + run: bundle exec rspec spec/integration/v4/ --format progress + env: + DATABASE_ENGINE: postgresql + PGHOST: 127.0.0.1 + PGPORT: '5432' + PGUSER: postgres + PGPASSWORD: postgres + - name: Verify RBAC specs ran (not silently skipped) + run: | + count=$(bundle exec rspec spec/integration/v4/ --tag rbac --format json 2>/dev/null | tail -1 | ruby -rjson -e 'puts JSON.parse(STDIN.read)["summary"]["example_count"]') + echo "RBAC examples ran: $count" + if [ "$count" -lt 1 ]; then + echo "::error::All RBAC examples were skipped — role provisioning may have failed" + exit 1 + fi + env: + DATABASE_ENGINE: postgresql + PGHOST: 127.0.0.1 + PGPORT: '5432' + PGUSER: postgres + PGPASSWORD: postgres + + # ── MySQL integration tests ──────────────────────────────────────── + mysql: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.engine == 'all' || inputs.engine == 'mysql' }} + name: MySQL 8.4 · Ruby ${{ matrix.ruby }} · Rails ${{ matrix.rails }} + runs-on: ubuntu-latest + needs: unit + strategy: + fail-fast: false + matrix: + ruby: ${{ github.event_name == 'workflow_dispatch' && inputs.ruby != 'all' && fromJSON(format('["{0}"]', inputs.ruby)) || fromJSON('["3.3","3.4","4.0"]') }} + rails: ${{ github.event_name == 'workflow_dispatch' && inputs.rails != 'all' && fromJSON(format('["{0}"]', inputs.rails)) || fromJSON('["7.2","8.0","8.1"]') }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}_mysql2.gemfile + CI: true + RAILS_ENV: test + DATABASE_ENGINE: mysql + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: apartment_mysql_test + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Provision RBAC test roles + shell: bash + run: | + set -euo pipefail + mysql -h 127.0.0.1 -u root <<'SQL' + CREATE USER IF NOT EXISTS 'apt_test_db_manager'@'%'; + CREATE USER IF NOT EXISTS 'apt_test_app_user'@'%'; + GRANT ALL PRIVILEGES ON *.* TO 'apt_test_db_manager'@'%' WITH GRANT OPTION; + FLUSH PRIVILEGES; + SQL + - name: Run v4 integration tests + run: bundle exec rspec spec/integration/v4/ --format progress + env: + DATABASE_ENGINE: mysql + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: '3306' + MYSQL_USER: root + - name: Verify RBAC specs ran (not silently skipped) + run: | + count=$(bundle exec rspec spec/integration/v4/ --tag rbac --format json 2>/dev/null | tail -1 | ruby -rjson -e 'puts JSON.parse(STDIN.read)["summary"]["example_count"]') + echo "RBAC examples ran: $count" + if [ "$count" -lt 1 ]; then + echo "::error::All RBAC examples were skipped — role provisioning may have failed" + exit 1 + fi + env: + DATABASE_ENGINE: mysql + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: '3306' + MYSQL_USER: root + + # ── SQLite integration tests ─────────────────────────────────────── + sqlite: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.engine == 'all' || inputs.engine == 'sqlite' }} + name: SQLite · Ruby ${{ matrix.ruby }} · Rails ${{ matrix.rails }} + runs-on: ubuntu-latest + needs: unit + strategy: + fail-fast: false + matrix: + ruby: ${{ github.event_name == 'workflow_dispatch' && inputs.ruby != 'all' && fromJSON(format('["{0}"]', inputs.ruby)) || fromJSON('["3.3","3.4","4.0"]') }} + rails: ${{ github.event_name == 'workflow_dispatch' && inputs.rails != 'all' && fromJSON(format('["{0}"]', inputs.rails)) || fromJSON('["7.2","8.0","8.1"]') }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}_sqlite3.gemfile + CI: true + RAILS_ENV: test + DATABASE_ENGINE: sqlite + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run v4 integration tests + run: bundle exec rspec spec/integration/v4/ --format progress + env: + DATABASE_ENGINE: sqlite diff --git a/.github/workflows/gem-publish.yml b/.github/workflows/gem-publish.yml new file mode 100644 index 00000000..c4396ff5 --- /dev/null +++ b/.github/workflows/gem-publish.yml @@ -0,0 +1,24 @@ +name: Publish to RubyGems + +on: + push: + tags: [ 'v*' ] + +jobs: + release: + name: Build + Publish + runs-on: ubuntu-latest + environment: production + permissions: + id-token: write # Required for trusted publishing to RubyGems.org + contents: write # Required by rubygems/release-gem (rake release pushes tags) + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: .ruby-version + - name: Publish to RubyGems + uses: rubygems/release-gem@v1 diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml new file mode 100644 index 00000000..f24e3c0b --- /dev/null +++ b/.github/workflows/rubocop.yml @@ -0,0 +1,21 @@ +name: Rubocop +on: + push: + branches: [development, main, '*-stable'] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + rubocop: + name: Rubocop + runs-on: ubuntu-latest + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_8.1_sqlite3.gemfile + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - name: Rubocop + run: bundle exec rubocop \ No newline at end of file diff --git a/.gitignore b/.gitignore index 01c3930e..0c3a8d24 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,10 @@ cookbooks tmp spec/dummy/db/*.sqlite3 .DS_Store +# VS Code files +.vscode/* +# Claude Code local settings (user-specific) +.claude/settings.local.json +# Firecrawl working data +.firecrawl/ +coverage/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..7b48bda2 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "rails-mcp-server": { + "type": "stdio", + "command": "rails-mcp-server", + "args": [] + } + } +} diff --git a/.rubocop.yml b/.rubocop.yml index b7c5c8b6..a99955de 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,96 +1,216 @@ -inherit_from: .rubocop_todo.yml - AllCops: + NewCops: enable Exclude: - - 'gemfiles/**/*.gemfile' - - 'gemfiles/vendor/**/*' - - 'spec/dummy_engine/dummy_engine.gemspec' + - vendor/bundle/**/* + - gemfiles/**/*.gemfile + - spec/dummy_engine/**/* + - gemfiles/vendor/**/* + - spec/dummy_engine/dummy_engine.gemspec + - spec/schemas/**/*.rb + +plugins: + - rubocop-rails + - rubocop-performance + - rubocop-thread_safety + - rubocop-rake + - rubocop-rspec Gemspec/RequiredRubyVersion: Exclude: - 'ros-apartment.gemspec' -Style/WordArray: +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented + +Metrics/BlockLength: + Max: 30 Exclude: - - spec/schemas/**/*.rb + - spec/**/*.rb + - lib/tasks/**/*.rake + - lib/apartment/tasks/**/*.rake + - Rakefile -Style/NumericLiterals: +Metrics/MethodLength: + Max: 15 Exclude: - - spec/schemas/**/*.rb + - spec/**/*.rb + - lib/apartment.rb + - lib/apartment/tenant.rb + - lib/apartment/config.rb + - lib/apartment/pool_reaper.rb -Layout/EmptyLineAfterMagicComment: +Metrics/ModuleLength: + Max: 150 Exclude: - - spec/schemas/**/*.rb + - spec/**/*.rb -Metrics/BlockLength: +Metrics/CyclomaticComplexity: + Exclude: + - lib/apartment.rb + - lib/apartment/config.rb + - lib/apartment/pool_reaper.rb + +Metrics/PerceivedComplexity: + Exclude: + - lib/apartment/config.rb + +Metrics/ParameterLists: + Exclude: + - lib/apartment/pool_reaper.rb + +Metrics/AbcSize: + Max: 20 Exclude: - spec/**/*.rb -Layout/EmptyLinesAroundAttributeAccessor: - Enabled: true +Rails/SkipsModelValidations: + Exclude: + - lib/apartment/pool_manager.rb -Layout/SpaceAroundMethodCallOperator: - Enabled: true +Metrics/ClassLength: + Max: 155 + Exclude: + - spec/**/*.rb -Lint/DeprecatedOpenSSLConstant: - Enabled: true +Rails/Delegate: + Exclude: + - lib/apartment/tenant.rb -Lint/DuplicateElsifCondition: - Enabled: true +Rails/RakeEnvironment: + Enabled: false -Lint/MixedRegexpCaptureTypes: - Enabled: true +Rails/ApplicationRecord: + Enabled: false -Lint/RaiseException: - Enabled: true +Rails/Output: + Enabled: false -Lint/StructNewOverride: - Enabled: true +Lint/EmptyBlock: + Exclude: + - spec/**/*.rb + - lib/apartment/instrumentation.rb -Style/AccessorGrouping: - Enabled: true +Style/Documentation: + Enabled: false -Style/ArrayCoercion: - Enabled: true +Style/StringLiterals: + EnforcedStyle: single_quotes -Style/BisectedAttrAccessor: - Enabled: true +Style/InlineComment: + Enabled: false -Style/CaseLikeIf: +Style/FrozenStringLiteralComment: Enabled: true + Exclude: + - Gemfile -Style/ExponentialNotation: +Style/MethodCallWithArgsParentheses: Enabled: true + EnforcedStyle: require_parentheses + AllowedPatterns: + - 'puts' + - 'info' + - 'warn' + - 'debug' + - 'error' + - 'fatal' + - 'fail' -Style/HashAsLastArrayItem: - Enabled: true +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma -Style/HashEachMethods: - Enabled: true +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma -Style/HashLikeCase: - Enabled: true +Style/ClassAndModuleChildren: + EnforcedStyle: nested + AutoCorrect: true -Style/HashTransformKeys: - Enabled: true +Style/CollectionMethods: + PreferredMethods: + collect: 'map' + collect!: 'map!' + inject: 'reduce' + detect: 'detect' + find_all: 'select' -Style/HashTransformValues: - Enabled: true +# RSpec style preferences - disable for mature test suite +RSpec/NamedSubject: + Enabled: false -Style/RedundantAssignment: - Enabled: true +RSpec/MultipleExpectations: + Enabled: false -Style/RedundantFetchBlock: - Enabled: true +RSpec/MessageSpies: + Enabled: false -Style/RedundantFileExtensionInRequire: - Enabled: true +RSpec/NestedGroups: + Enabled: false -Style/RedundantRegexpCharacterClass: - Enabled: true +RSpec/ContextWording: + Enabled: false -Style/RedundantRegexpEscape: - Enabled: true +RSpec/ExampleLength: + Enabled: false -Style/SlicingWithRange: - Enabled: true +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/VerifiedDoubleReference: + Enabled: false + +RSpec/StubbedMock: + Enabled: false + +RSpec/MultipleDescribes: + Enabled: false + +RSpec/NoExpectationExample: + Enabled: false + +# ThreadSafety - intentional design for configuration +ThreadSafety/ClassInstanceVariable: + Exclude: + - lib/apartment.rb + - lib/apartment/pool_reaper.rb + - lib/apartment/elevators/*.rb + - spec/support/config.rb + - spec/integration/v4/support/rbac_helper.rb + +ThreadSafety/ClassAndModuleAttributes: + Exclude: + - lib/apartment.rb + +ThreadSafety/DirChdir: + Exclude: + - ros-apartment.gemspec + +ThreadSafety/NewThread: + Exclude: + - spec/tenant_spec.rb + - spec/unit/**/*.rb + +# Rake cops +Rake/DuplicateTask: + Enabled: false \ No newline at end of file diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index c72ac883..00000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,66 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2020-07-16 04:15:41 UTC using RuboCop version 0.88.0. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 1 -Lint/MixedRegexpCaptureTypes: - Exclude: - - 'lib/apartment/elevators/domain.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -Lint/NonDeterministicRequireOrder: - Exclude: - - 'spec/spec_helper.rb' - -# Offense count: 7 -# Configuration parameters: IgnoredMethods. -Metrics/AbcSize: - Max: 33 - -# Offense count: 3 -# Configuration parameters: CountComments, CountAsOne, ExcludedMethods. -# ExcludedMethods: refine -Metrics/BlockLength: - Max: 102 - -# Offense count: 1 -# Configuration parameters: CountComments, CountAsOne. -Metrics/ClassLength: - Max: 151 - -# Offense count: 6 -# Configuration parameters: CountComments, CountAsOne, ExcludedMethods. -Metrics/MethodLength: - Max: 24 - -# Offense count: 17 -Style/Documentation: - Exclude: - - 'spec/**/*' - - 'test/**/*' - - 'lib/apartment/adapters/jdbc_mysql_adapter.rb' - - 'lib/apartment/adapters/postgis_adapter.rb' - - 'lib/apartment/adapters/postgresql_adapter.rb' - - 'lib/apartment/adapters/sqlite3_adapter.rb' - - 'lib/apartment/custom_console.rb' - - 'lib/apartment/deprecation.rb' - - 'lib/apartment/migrator.rb' - - 'lib/apartment/model.rb' - - 'lib/apartment/railtie.rb' - - 'lib/apartment/reloader.rb' - - 'lib/apartment/tasks/enhancements.rb' - - 'lib/apartment/tasks/task_helper.rb' - - 'lib/generators/apartment/install/install_generator.rb' - -# Offense count: 3 -# Cop supports --auto-correct. -Style/IfUnlessModifier: - Exclude: - - 'Rakefile' - - 'lib/apartment.rb' - - 'lib/apartment/tenant.rb' diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..4d54dadd --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.2 diff --git a/.story_branch.yml b/.story_branch.yml deleted file mode 100644 index 4143ff44..00000000 --- a/.story_branch.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -tracker: github -issue_placement: beginning -project_id: -- rails-on-services/apartment diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..03cde6cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Guidelines for AI Agents + +This repo uses `CLAUDE.md` files as authoritative contributor guides for all AI assistants. + +## Reading Order + +1. **Read `/CLAUDE.md` (root) first** - project-wide architecture, patterns, design decisions +2. **Read directory-specific `CLAUDE.md`** - check the directory you're modifying and its parents +3. **Nested files override root** on conflicts (per AGENTS.md spec) + +Nested `CLAUDE.md` files exist in `lib/apartment/`, `lib/apartment/adapters/`, `lib/apartment/elevators/`, `lib/apartment/tasks/`, and `spec/`. + +## Key Principle + +Check `CLAUDE.md` before copying patterns from existing code - it documents preferred patterns, design rationale, and known pitfalls. + +## Adding Documentation + +Update the appropriate `CLAUDE.md` rather than this file. This file exists only as a pointer. diff --git a/Appraisals b/Appraisals index 5f55c5ac..aa1acd19 100644 --- a/Appraisals +++ b/Appraisals @@ -1,70 +1,87 @@ # frozen_string_literal: true -appraise 'rails-5-0' do - gem 'rails', '~> 5.0.0' - platforms :ruby do - gem 'pg', '< 1.0.0' - end - platforms :jruby do - gem 'activerecord-jdbc-adapter', '~> 50.0' - gem 'activerecord-jdbcpostgresql-adapter', '~> 50.0' - gem 'activerecord-jdbcmysql-adapter', '~> 50.0' - end +# Apartment v4 support matrix: Rails 7.2+ × PostgreSQL/MySQL/SQLite3 +# No JDBC (JRuby dropped in v4). No Rails < 7.2 (gemspec requires >= 7.2). +# +# Usage: +# bundle exec appraisal install # install all appraisals +# bundle exec appraisal rspec spec/unit/ # run against all Rails versions +# bundle exec appraisal rails-7.2-postgresql rspec spec/unit/ # single appraisal + +# --- Rails 7.2 --- + +appraise 'rails-7.2-postgresql' do + gem 'rails', '~> 7.2.0' + gem 'pg', '~> 1.5' +end + +appraise 'rails-7.2-mysql2' do + gem 'rails', '~> 7.2.0' + gem 'mysql2', '~> 0.5' +end + +appraise 'rails-7.2-trilogy' do + gem 'rails', '~> 7.2.0' + gem 'trilogy', '>= 2.9' +end + +appraise 'rails-7.2-sqlite3' do + gem 'rails', '~> 7.2.0' + gem 'sqlite3', '~> 2.1' +end + +# --- Rails 8.0 --- + +appraise 'rails-8.0-postgresql' do + gem 'rails', '~> 8.0.0' + gem 'pg', '~> 1.5' end -appraise 'rails-5-1' do - gem 'rails', '~> 5.1.0' - platforms :ruby do - gem 'pg', '< 1.0.0' - end - platforms :jruby do - gem 'activerecord-jdbc-adapter', '~> 51.0' - gem 'activerecord-jdbcpostgresql-adapter', '~> 51.0' - gem 'activerecord-jdbcmysql-adapter', '~> 51.0' - end +appraise 'rails-8.0-mysql2' do + gem 'rails', '~> 8.0.0' + gem 'mysql2', '~> 0.5' end -appraise 'rails-5-2' do - gem 'rails', '~> 5.2.0' - platforms :jruby do - gem 'activerecord-jdbc-adapter', '~> 52.0' - gem 'activerecord-jdbcpostgresql-adapter', '~> 52.0' - gem 'activerecord-jdbcmysql-adapter', '~> 52.0' - end +appraise 'rails-8.0-trilogy' do + gem 'rails', '~> 8.0.0' + gem 'trilogy', '>= 2.9' end -appraise 'rails-6-0' do - gem 'rails', '~> 6.0.0' - platforms :ruby do - gem 'sqlite3', '~> 1.4' - end - platforms :jruby do - gem 'activerecord-jdbc-adapter', '~> 60.0' - gem 'activerecord-jdbcpostgresql-adapter', '~> 60.0' - gem 'activerecord-jdbcmysql-adapter', '~> 60.0' - end +appraise 'rails-8.0-sqlite3' do + gem 'rails', '~> 8.0.0' + gem 'sqlite3', '~> 2.1' end -appraise 'rails-6-1' do - gem 'rails', '~> 6.1.0' - platforms :ruby do - gem 'sqlite3', '~> 1.4' - end - platforms :jruby do - gem 'activerecord-jdbc-adapter', '~> 61.0' - gem 'activerecord-jdbcpostgresql-adapter', '~> 61.0' - gem 'activerecord-jdbcmysql-adapter', '~> 61.0' - end +# --- Rails 8.1 --- + +appraise 'rails-8.1-postgresql' do + gem 'rails', '~> 8.1.0' + gem 'pg', '~> 1.6' +end + +appraise 'rails-8.1-mysql2' do + gem 'rails', '~> 8.1.0' + gem 'mysql2', '~> 0.5' +end + +appraise 'rails-8.1-trilogy' do + gem 'rails', '~> 8.1.0' + gem 'trilogy', '>= 2.9' +end + +appraise 'rails-8.1-sqlite3' do + gem 'rails', '~> 8.1.0' + gem 'sqlite3', '~> 2.8' +end + +# --- Rails main (catch regressions early) --- + +appraise 'rails-main-postgresql' do + gem 'rails', github: 'rails/rails', branch: 'main' + gem 'pg', '~> 1.6' end -appraise 'rails-master' do - gem 'rails', git: 'https://github.com/rails/rails.git' - platforms :ruby do - gem 'sqlite3', '~> 1.4' - end - platforms :jruby do - gem 'activerecord-jdbc-adapter', '~> 61.0' - gem 'activerecord-jdbcpostgresql-adapter', '~> 61.0' - gem 'activerecord-jdbcmysql-adapter', '~> 61.0' - end +appraise 'rails-main-sqlite3' do + gem 'rails', github: 'rails/rails', branch: 'main' + gem 'sqlite3', '~> 2.8' end diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a1fee9a0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md - Apartment + +**Gem Name**: `ros-apartment` +**Maintained by**: CampusESP +**Active work**: v4 rewrite (phased, PR-per-sub-phase off `development`; renaming to `main` pending) + +## Design & Plan Documents + +Planning artifacts live in `docs/` with no date prefixes (git handles temporal tracking): + +- `docs/designs/.md` — Design specs (what and why). Living docs, one per feature, updated in place. +- `docs/plans//` — Implementation plans (how and in what order). Can have multiple files for phased plans. + +Do NOT use `docs/superpowers/specs/` or `docs/superpowers/plans/` — those are plugin defaults that we override with the paths above. + +**Key documents:** +- `docs/designs/apartment-v4.md` — v4 design spec +- `docs/designs/v4-railtie-test-infra.md` — Railtie + test infrastructure design +- `docs/plans/apartment-v4/phase-2-adapters.md` — Current phase plan (includes deferred review items) + +## Where to Start + +1. **README.md** - Installation, basic usage, configuration options +2. **docs/architecture.md** - Core design decisions and WHY they were made (v3) +3. **docs/designs/apartment-v4.md** - v4 architecture and motivation +4. **lib/apartment/CLAUDE.md** - Implementation file guide +5. **spec/CLAUDE.md** - Test organization and patterns + +## Commands + +```bash +# Unit tests (no database required) +bundle exec rspec spec/unit/ + +# Unit tests across Rails versions +bundle exec appraisal install # first time only +bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/ # single version +bundle exec appraisal rspec spec/unit/ # all versions + +# v4 integration tests (requires real databases) +bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ # SQLite +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ # PostgreSQL +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ # MySQL + +# Lint +bundle exec rubocop + +# Build gem +gem build ros-apartment.gemspec + +# Coverage report (opt-in) +COVERAGE=1 bundle exec rspec spec/unit/ + +# Test profiling +FPROF=1 bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ +EVENT_PROF=sql.active_record bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ + +# Request lifecycle tests (requires PostgreSQL) +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/request_lifecycle_spec.rb + +# RBAC integration tests (requires provisioned PG/MySQL roles; see docs/designs/v4-phase5.2-rbac-integration-tests.md) +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ --tag rbac +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ --tag rbac +``` + +**CI matrix**: Ruby 3.3/3.4/4.0 × Rails 7.2/8.0/8.1 × PG 16+18, MySQL 8.4, SQLite3. See `.github/workflows/ci.yml`. + +## Core Concepts + +**Multi-tenancy via database isolation**: One app, many customers, data fully separated. +- **PostgreSQL (schemas)**: Namespaces in single DB. Fast (<1ms switch), scales to 100+ tenants. +- **MySQL (databases)**: Separate DB per tenant. Complete isolation, slower switching. +- **Elevators**: Rack middleware extracts tenant from request. Must be before session middleware. +- **Pinned models**: Global tables declared with `Apartment::Model` + `pin_tenant`. Bypasses tenant routing. Use `has_many :through`, not HABTM. Replaces `excluded_models` (deprecated in v4). + +See `docs/architecture.md` for v3 design decisions, `docs/adapters.md` for strategy trade-offs, `docs/elevators.md` for middleware rationale. + +## Key Patterns + +- **Block-based switching**: Always prefer `switch(tenant) { ... }` over `switch!`. Ensure block guarantees cleanup on exceptions. +- **Adapter pattern**: Abstract base class with database-specific subclasses. Unified API hides DB differences. +- **Callbacks**: `ActiveSupport::Callbacks` on `:create` and `:switch` for logging/notification hooks. +- **Dynamic tenant discovery**: `tenants_provider` is a callable (proc/lambda) that queries the database at runtime. +- **Tenant name validation**: `TenantNameValidator` does pure in-memory format checks (no DB queries). Enforced in `AbstractAdapter#create` and `ConnectionHandling#connection_pool`. Engine-specific rules for PG identifiers, MySQL names, SQLite paths. + +## Testing + +```bash +bundle exec rspec spec/unit/ # v4 unit tests (585 specs) +bundle exec appraisal rspec spec/unit/ # across all Rails versions +``` + +v4 unit tests are in `spec/unit/` and require no database. See `spec/CLAUDE.md` for test organization. + +## v4 Rewrite + +**Design spec**: `docs/designs/apartment-v4.md` + +**Major changes**: Pool-per-tenant (vs thread-local switching), fiber-safe via `CurrentAttributes`, immutable connection config per pool, `Config#freeze!` after validation + +**Why v4**: Fixes thread-local tenant leakage (e.g., ActionCable shared thread pool bugs). Adds fiber safety, PgBouncer/RDS Proxy transaction mode compatibility, and a simpler mental model. + +**Status**: Phases 1, 2.1, 2.2, 2.3 merged. Phase 2.4 merged. Railtie + test infrastructure complete. See `docs/plans/apartment-v4/` for full plan. + +## Gotchas + +- **v3 removal**: v3 files were deleted as of Phase 2.5. `lib/apartment/` contains only v4 code. The v3 elevators in `lib/apartment/elevators/` remain Zeitwerk-ignored until Phase 3 replaces them. +- **Frozen config**: `Apartment.config` is frozen after `Apartment.configure`. Tests that need different config values must call `Apartment.configure` again (not stub the frozen object). +- **Monotonic clock**: `PoolManager` uses `Process.clock_gettime(Process::CLOCK_MONOTONIC)` for timestamps, not `Time.now`. Stats return `seconds_idle` (duration), not wall-clock times. +- **schema_load_strategy**: Defaults to `nil` (no schema loading on create). Set to `:schema_rb` or `:sql` to auto-load schema into new tenants. +- **v4 Railtie**: `lib/apartment/railtie.rb` is now v4. It auto-wires `activate!`, `init`, middleware, and rake tasks after `Apartment.configure` runs. No manual middleware insertion needed. +- **`connects_to` edge case**: Models (or abstract base classes) that use `connects_to` to point at a separate database need `pin_tenant` to prevent Apartment from creating tenant pools for them. The common pattern of `ApplicationRecord` using `connects_to` with multiple roles (writing/reading) on the same database works correctly without any special handling. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..71a9cb54 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,71 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a safe, welcoming, and inclusive experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [mauricio@campusesp.com]. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. + +### 4. Permanent Ban +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq. \ No newline at end of file diff --git a/Gemfile b/Gemfile index 0949319d..70c675ea 100644 --- a/Gemfile +++ b/Gemfile @@ -1,13 +1,23 @@ # frozen_string_literal: true -source 'http://rubygems.org' +source 'https://rubygems.org' gemspec -gem 'rails', '>= 3.1.2' -gem 'rubocop' +gem 'appraisal', '~> 2.5' +gem 'rack-test', require: false +gem 'rspec', '~> 3.10' -group :local do - gem 'guard-rspec', '~> 4.2' - gem 'pry' +group :development do + gem 'rubocop', require: false + gem 'rubocop-performance', require: false + gem 'rubocop-rails', require: false + gem 'rubocop-rake', require: false + gem 'rubocop-rspec', require: false + gem 'rubocop-thread_safety', require: false +end + +group :development, :test do + gem 'simplecov', require: false + gem 'test-prof', require: false end diff --git a/HISTORY.md b/HISTORY.md deleted file mode 100644 index 0167efa5..00000000 --- a/HISTORY.md +++ /dev/null @@ -1,496 +0,0 @@ -# v2.7.1 - -**Implemented enhancements:** - -- N/a - -**Fixed bugs:** - -- [Resolves #82] Enhanced db:create breaks plugin compatibility - - -**Closed issues:** - -- Update rake version in development -- Renamed gemspec to match gem name - -# v2.7.0 - -**Implemented enhancements:** - -- [Resolves #70] Rake tasks define methods on main - -- Add database and schema to active record log. Configurable, defaults to false to keep current behavior - - -**Fixed bugs:** - -- [Fixes #61] Fix database create in mysql - - -**Closed issues:** - -- Remove deprecated tld_length config option: tld_length was removed in influitive#309, this configuration option doesn't have any effect now. - -- Using [diffend.io proxy](https://diffend.io) to safely check required gems -- Added [story branch](https://github.com/story-branch/story_branch) to the configuration -- Using travis-ci to run rubocop as well, replacing github actions: github actions do not work in fork's PRs - -# v2.6.1 - -**Implemented enhancements:** -- N/a - -**Fixed bugs:** -- [Resolves influitive#607] Avoid early connection - - - - - - -- [Resolves #52] Rake db:setup tries to seed non existent tenant - -- [Resolves #56] DB rollback uses second last migration - - -#**Closed issues:** -- N/a - -# v2.6.0 - -**Implemented enhancements:** -- [Resolves #26] Support configuration for skip checking of schema existence before switching -- [Resolves #41] Add tenant info to console boot - -**Fixed bugs:** -- [Resolves #37] Custom Console deprecation warning -- [Resolves #42] After switch callback not working with nil argument - -#**Closed issues:** -- Updated github actions configuration to run on PRs as well - -# v2.5.0 - -**Implemented enhancements:** -- [Resolves #6] Adds support for rails 6.1 -- [Resolves #27] Adds support to not rely on set search path, but instead prepends the schema name to the table name when using postgresql with schemas. -- [Resolves #35] Cache keys are now tenant dependent - -**Fixed bugs:** -- [Resolves #27] Manually switching connection between read and write forgets the schema - -#**Closed issues:** -- [Resolves #31] Add latest ruby versions to test matrix - -# v2.4.0 - -**Implemented enhancements:** -- [Resolves #14] Add console info about tenants and fast switches #17 -- Skip init if we're running webpacker:compile #18 - -**Fixed bugs:** -- Don't crash when no database connection is present #16 -- Rescuing ActiveRecord::NoDatabaseError when dropping tenants #19 - -#**Closed issues:** -- Rakefile should use mysql port from configuration #5 -- [Resolves #9] Cleanup rubocop todo #8 -- Cleanup travis matrix #23 - -# v2.3.0 - * January 3, 2020 - -**Implemented enhancements:** - - Basic support for rails 6 - - Released different gem name, with same API as apartment - -# v2.2.1 - * June 19, 2019 - -**Implemented enhancements:** - - #566: IGNORE_EMPTY_TENANTS environment variable to ignore empty tenants - warning. [Pysis868] - -**Fixed bugs:** - - #586: Ignore `CREATE SCHEMA public` statement in pg dump [artemave] - - #549: Fix Postgres schema creation with dump SQL [ancorcruz] - -# v2.2.0 - * April 14, 2018 - -**Implemented enhancements:** - - #523: Add Rails 5.2 support [IngusSkaistkalns] - - #504: Test against Ruby 2.5.0 [ahorek] - - #528: Test against Rails 5.2 [meganemura] - -**Removed:** - - #504: Remove Rails 4.0/4.1 support [ahorek] - - #545: Stop supporting for JRuby + Rails 5.0 [meganemura] - -**Fixed bugs:** - - #537: Fix PostgresqlSchemaFromSqlAdapter for newer PostgreSQL [shterrett] - - #532: Issue is reported by [aldrinmartoq] - - #519: Fix exception when main database doesn't exist [mayeco] - -**Closed issues:** - - - #514: Fix typo [menorval] - -# v2.1.0 - * December 15, 2017 - - - Add `parallel_migration_threads` configuration option for running migrations - in parallel [ryanbrunner] - - Drop Ruby 2.0.0 support [meganemura] - - ignore_private when parsing subdomains with PublicSuffix [michiomochi] - - Ignore row_security statements in psql dumps for backward compatibility - [meganemura] - - "Host" elevator [shrmnk] - - Enhance db:drop task to act on all tenants [kuzukuzu] - -# v2.0.0 - * July 26, 2017 - - - Raise FileNotFound rather than abort when loading files [meganemura] - - Add 5.1 support with fixes for deprecations [meganemura] - - Fix tests for 5.x and a host of dev-friendly improvements [meganemura] - - Keep query cache config after switching databases [fernandomm] - - Pass constants not strings to middleware stack (Rails 5) [tzabaman] - - Remove deprecations from 1.0.0 [caironoleto] - - Replace `tld_length` configuration option with PublicSuffix gem for the - subdomain elevator [humancopy] - - Pass full config to create_database to allow :encoding/:collation/etc - [kakipo] - - Don't retain a connection during initialization [mikecmpbll] - - Fix database name escaping in drop_command [mikecmpbll] - - Skip initialization for assets:clean and assets:precompile tasks - [frank-west-iii] - -# v1.2.0 - * July 28, 2016 - - - Official Rails 5 support - -# v1.1.0 - * May 26, 2016 - - - Reset tenant after each request - - [Support callbacks](https://github.com/influitive/apartment/commit/ff9c9d092a781026502f5997c0bbedcb5748bc83) on switch [cbeer] - - Preliminary support for [separate database hosts](https://github.com/influitive/apartment/commit/abdffbf8cd9fba87243f16c86390da13e318ee1f) [apneadiving] - -# v1.0.2 - * July 2, 2015 - - - Fix pg_dump env vars - pull/208 [MitinPavel] - - Allow custom seed data file - pull/234 [typeoneerror] - -# v1.0.1 - * April 28, 2015 - - - Fix `Apartment::Deprecation` which was rescuing all exceptions - -# v1.0.0 - * Feb 3, 2015 - - - [BREAKING CHANGE] `Apartment::Tenant.process` is deprecated in favour of `Apartment::Tenant.switch` - - [BREAKING CHANGE] `Apartment::Tenant.switch` without a block is deprecated in favour of `Apartment::Tenant.switch!` - - Raise proper `TenantNotFound`, `TenantExists` exceptions - - Deprecate old `SchemaNotFound`, `DatabaseNotFound` exceptions - -# v0.26.1 - * Jan 13, 2015 - - - Fixed [schema quoting bug](https://github.com/influitive/apartment/issues/198#issuecomment-69782651) [jonsgreen] - -# v0.26.0 - * Jan 5, 2015 - - - Rails 4.2 support - -# v0.25.2 - * Sept 8, 2014 - - - Heroku fix on `assets:precompile` - pull/169 [rabbitt] - -# v0.25.1 - * July 17, 2014 - - - Fixed a few vestiges of Apartment::Database - -# v0.25.0 - * July 3, 2014 - - - [BREAKING CHANGE] - `Apartment::Database` is not deprecated in favour of - `Apartment::Tenant` - - ActiveRecord (and Rails) 4.1 now supported - - A new sql based adapter that dumps the schema using sql - -# v0.24.3 - * March 5, 2014 - - - Rake enhancements weren't removed from the generator template - -# v0.24.2 - * February 24, 2014 - - - Better warnings if `apartment:migrate` is run - -# v0.24.1 - * February 21, 2014 - - - requiring `apartment/tasks/enhancements` in an initializer doesn't work - - One can disable tenant migrations using `Apartment.db_migrate_tenants = false` in the Rakefile - -# v0.24 - * February 21, 2014 (In honour of the Women's Gold Medal in Hockey at Sochi) - - - [BREAKING CHANGE] `apartment:migrate` task no longer depends on `db:migrate` - - Instead, you can `require 'apartment/tasks/enhancements'` in your Apartment initializer - - This will enhance `rake db:migrate` to also run `apartment:migrate` - - You can now forget about ever running `apartment:migrate` again - - Numerous deprecations for things referencing the word 'database' - - This is an ongoing effort to completely replace 'database' with 'tenant' as a better abstraction - - Note the obvious `Apartment::Database` still exists but will hopefully become `Apartment::Tenant` soon - -# v0.23.2 - * January 9, 2014 - - - Increased visibility of #parse_database_name warning - -# v0.23.1 - * January 8, 2014 - - - Schema adapters now initialize with default and persistent schemas - - Deprecated Apartment::Elevators#parse_database_name - -# v0.23.0 - * August 21, 2013 - - - Subdomain Elevator now allows for exclusions - - Delayed::Job has been completely removed - -# v0.22.1 - * August 21, 2013 - - - Fix bug where if your ruby process importing the database schema is run - from a directory other than the app root, Apartment wouldn't know what - schema_migrations to insert into the database (Rails only) - -# v0.22.0 - * June 9, 2013 - - - Numerous bug fixes: - - Mysql reset could connect to wrong database [eric88] - - Postgresql schema names weren't quoted properly [gdott9] - - Fixed error message on SchemaNotFound in `process` - - HostHash elevator allows mapping host based on hash contents [gdott9] - - Official Sidekiq support with the [apartment-sidekiq gem](https://github.com/influitive/apartment-sidekiq) - - -# v0.21.1 - * May 31, 2013 - - - Clearing the AR::QueryCache after switching databases. - - Fixes issue with stale model being loaded for schema adapters - -# v0.21.0 - * April 24, 2013 - - - JDBC support!! [PetrolMan] - -# v0.20.0 - * Feb 6, 2013 - - - Mysql now has a 'schema like' option to perform like Postgresql (default) - - This should be significantly more performant than using connections - - Psych is now supported for Delayed::Job yaml parsing - -# v0.19.2 - * Jan 30, 2013 - - - Database schema file can now be set manually or skipped altogether - -# v0.19.1 - * Jan 30, 2013 - - - Allow schema.rb import file to be specified in config or skip schema.rb import altogether - -# v0.19.0 - * Dec 29, 2012 - - - Apartment is now threadsafe - - New postgis adapter [zonpantli] - - Removed ActionDispatch dependency for use with Rack apps (regression) - -# v0.18.0 - * Nov 27, 2012 - - - Added `append_environment` config option [virtualstaticvoid] - - Cleaned up the readme and generator documentation - - Added `connection_class` config option [smashtank] - - Fixed a [bug](https://github.com/influitive/apartment/issues/17#issuecomment-10758327) in pg adapter when missing schema - -# v0.17.1 - * Oct 30, 2012 - - - Fixed a bug where switching to an unknown db in mysql2 would crash the app [Frodotus] - -# v0.17.0 - * Sept 26, 2012 - - - Apartment has [a new home!](https://github.com/influitive/apartment) - - Support Sidekiq hooks to switch dbs [maedhr] - - Allow VERSION to be used on apartment:migrate [Bhavin Kamani] - -# v0.16.0 - * June 1, 2012 - - - Apartment now supports a default_schema to be set, rather than relying on ActiveRecord's default schema_search_path - - Additional schemas can always be maintained in the schema_search_path by configuring persistent_schemas [ryanbrunner] - - This means Hstore is officially supported!! - - There is now a full domain based elevator to switch dbs based on the whole domain [lcowell] - - There is now a generic elevator that takes a Proc to switch dbs based on the return value of that proc. - -# v0.15.0 - * March 18, 2012 - - - Remove Rails dependency, Apartment can now be used with any Rack based framework using ActiveRecord - -# v0.14.4 - * March 8, 2012 - - - Delayed::Job Hooks now return to the previous database, rather than resetting - -# v0.14.3 - * Feb 21, 2012 - - - Fix yaml serialization of non DJ models - -# v0.14.2 - * Feb 21, 2012 - - - Fix Delayed::Job yaml encoding with Rails > 3.0.x - -# v0.14.1 - * Dec 13, 2011 - - - Fix ActionDispatch::Callbacks deprecation warnings - -# v0.14.0 - * Dec 13, 2011 - - - Rails 3.1 Support - -# v0.13.1 - * Nov 8, 2011 - - - Reset prepared statement cache for rails 3.1.1 before switching dbs when using postgresql schemas - - Only necessary until the next release which will be more schema aware - -# v0.13.0 - * Oct 25, 2011 - - - `process` will now rescue with reset if the previous schema/db is no longer available - - `create` now takes an optional block which allows you to process within the newly created db - - Fixed Rails version >= 3.0.10 and < 3.1 because there have been significant testing problems with 3.1, next version will hopefully fix this - -# v0.12.0 - * Oct 4, 2011 - - - Added a `drop` method for removing databases/schemas - - Refactored abstract adapter to further remove duplication in concrete implementations - - Excluded models now take string references so they are properly reloaded in development - - Better silencing of `schema.rb` loading using `verbose` flag - -# v0.11.1 - * Sep 22, 2011 - - - Better use of Railties for initializing apartment - - The following changes were necessary as I haven't figured out how to properly hook into Rails reloading - - Added reloader middleware in development to init Apartment on each request - - Override `reload!` in console to also init Apartment - -# v0.11.0 - * Sep 20, 2011 - - - Excluded models no longer use a different connection when using postgresql schemas. Instead their table_name is prefixed with `public.` - -# v0.10.3 - * Sep 20, 2011 - - - Fix improper raising of exceptions on create and reset - -# v0.10.2 - * Sep 15, 2011 - - - Remove all the annoying logging for loading db schema and seeding on create - -# v0.10.1 - * Aug 11, 2011 - - - Fixed bug in DJ where new objects (that hadn't been pulled from the db) didn't have the proper database assigned - -# v0.10.0 - * July 29, 2011 - - - Added better support for Delayed Job - - New config option that enables Delayed Job wrappers - - Note that DJ support uses a work-around in order to get queues stored in the public schema, not sure why it doesn't work out of the box, will look into it, until then, see documentation on queue'ng jobs - -# v0.9.2 - * July 4, 2011 - - - Migrations now run associated rails migration fully, fixes schema.rb not being reloaded after migrations - -# v0.9.1 - * June 24, 2011 - - - Hooks now take the payload object as an argument to fetch the proper db for DJ hooks - -# v0.9.0 - * June 23, 2011 - - - Added module to provide delayed job hooks - -# v0.8.0 - * June 23, 2011 - - - Added #current_database which will return the current database (or schema) name - -# v0.7.0 - * June 22, 2011 - - - Added apartment:seed rake task for seeding all dbs - -# v0.6.0 - * June 21, 2011 - - - Added #process to connect to new db, perform operations, then ensure a reset - -# v0.5.1 - * June 21, 2011 - - - Fixed db migrate up/down/rollback - - added db:redo - -# v0.5.0 - * June 20, 2011 - - - Added the concept of an "Elevator", a rack based strategy for db switching - - Added the Subdomain Elevator middleware to enabled db switching based on subdomain - -# v0.4.0 - * June 14, 2011 - - - Added `configure` method on Apartment instead of using yml file, allows for dynamic setting of db names to migrate for rake task - - Added `seed_after_create` config option to import seed data to new db on create - -# v0.3.0 - * June 10, 2011 - - - Added full support for database migration - - Added in method to establish new connection for excluded models on startup rather than on each switch - -# v0.2.0 - * June 6, 2011 * - - - Refactor to use more rails/active_support functionality - - Refactor config to lazily load apartment.yml if exists - - Remove OStruct and just use hashes for fetching methods - - Added schema load on create instead of migrating from scratch - -# v0.1.3 - * March 30, 2011 * - - - Original pass from Ryan diff --git a/README.md b/README.md index fa033c09..7ac93461 100644 --- a/README.md +++ b/README.md @@ -1,644 +1,345 @@ # Apartment -[![Gem Version](https://badge.fury.io/rb/ros-apartment.svg)](https://badge.fury.io/rb/apartment) -[![Code Climate](https://api.codeclimate.com/v1/badges/b0dc327380bb8438f991/maintainability)](https://codeclimate.com/github/rails-on-services/apartment/maintainability) -[![Build Status](https://travis-ci.org/rails-on-services/apartment.svg?branch=development)](https://travis-ci.org/rails-on-services/apartment) +[![Gem Version](https://badge.fury.io/rb/ros-apartment.svg)](https://badge.fury.io/rb/ros-apartment) +[![CI](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml/badge.svg)](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/rails-on-services/apartment/graph/badge.svg?token=Q4I5QL78SA)](https://codecov.io/gh/rails-on-services/apartment) -*Multitenancy for Rails and ActiveRecord* +*Database-level multitenancy for Rails and ActiveRecord* -Apartment provides tools to help you deal with multiple tenants in your Rails -application. If you need to have certain data sequestered based on account or company, -but still allow some data to exist in a common tenant, Apartment can help. - -## Apartment drop in replacement gem - -After having reached out via github issues and email directly, no replies ever -came. Since we wanted to upgrade our application to Rails 6 we decided to fork -and start some development to support Rails 6. Because we don't have access -to the apartment gem itself, the solution was to release it under a different -name but providing the exact same API as it was before. - -## Help wanted - -We were never involved with the development of Apartment gem in the first place -and this project started out of our own needs. We will be more than happy -to collaborate to maintain the gem alive and supporting the latest versions -of ruby and rails, but your help is appreciated. Either by reporting bugs you -may find or proposing improvements to the gem itself. Feel free to reach out. - -## Installation - -### Rails - -Add the following to your Gemfile: - -```ruby -gem 'ros-apartment', require: 'apartment' -``` - -Then generate your `Apartment` config file using +Apartment isolates tenant data at the **database level** — using PostgreSQL schemas or separate databases — so that tenant data separation is enforced by the database engine, not application code. ```ruby -bundle exec rails generate apartment:install -``` - -This will create a `config/initializers/apartment.rb` initializer file. -Configure as needed using the docs below. - -That's all you need to set up the Apartment libraries. If you want to switch tenants -on a per-user basis, look under "Usage - Switching tenants per request", below. - -> NOTE: If using [postgresql schemas](http://www.postgresql.org/docs/9.0/static/ddl-schemas.html) you must use: -> -> * for Rails 3.1.x: _Rails ~> 3.1.2_, it contains a [patch](https://github.com/rails/rails/pull/3232) that makes prepared statements work with multiple schemas - -## Usage - -### Video Tutorial - -How to separate your application data into different accounts or companies. -[GoRails #47](https://gorails.com/episodes/multitenancy-with-apartment) - -### Creating new Tenants - -Before you can switch to a new apartment tenant, you will need to create it. Whenever -you need to create a new tenant, you can run the following command: - -```ruby -Apartment::Tenant.create('tenant_name') -``` - -If you're using the [prepend environment](https://github.com/influitive/apartment#handling-environments) config option or you AREN'T using Postgresql Schemas, this will create a tenant in the following format: "#{environment}\_tenant_name". -In the case of a sqlite database, this will be created in your 'db/' folder. With -other databases, the tenant will be created as a new DB within the system. - -When you create a new tenant, all migrations will be run against that tenant, so it will be -up to date when create returns. - -#### Notes on PostgreSQL - -PostgreSQL works slightly differently than other databases when creating a new tenant. If you -are using PostgreSQL, Apartment by default will set up a new [schema](http://www.postgresql.org/docs/9.3/static/ddl-schemas.html) -and migrate into there. This provides better performance, and allows Apartment to work on systems like Heroku, which -would not allow a full new database to be created. - -One can optionally use the full database creation instead if they want, though this is not recommended - -### Switching Tenants - -To switch tenants using Apartment, use the following command: - -```ruby -Apartment::Tenant.switch('tenant_name') do - # ... +Apartment::Tenant.switch('acme') do + User.all # only returns users in the 'acme' schema/database end ``` -When switch is called, all requests coming to ActiveRecord will be routed to the tenant -you specify (with the exception of excluded models, see below). The tenant is automatically -switched back at the end of the block to what it was before. +## When to Use Apartment -There is also `switch!` which doesn't take a block, but it's recommended to use `switch`. -To return to the default tenant, you can call `switch` with no arguments. +Apartment uses **schema-per-tenant** (PostgreSQL) or **database-per-tenant** (MySQL/SQLite) isolation. This is one of several approaches to multitenancy in Rails. Choose the right one for your situation: -### Switching Tenants per request +| Approach | Isolation | Best for | Gem | +|----------|-----------|----------|-----| +| **Row-level** (shared tables, `WHERE tenant_id = ?`) | Application-enforced | Many tenants, greenfield apps, cross-tenant reporting | [`acts_as_tenant`](https://github.com/ErwinM/acts_as_tenant) | +| **Schema-level** (PostgreSQL schemas) | Database-enforced | Fewer high-value tenants, regulatory requirements, retrofitting existing apps | `ros-apartment` | +| **Database-level** (separate databases) | Full isolation | Strictest isolation, per-tenant performance tuning | `ros-apartment` | -You can have Apartment route to the appropriate tenant by adding some Rack middleware. -Apartment can support many different "Elevators" that can take care of this routing to your data. +**Use Apartment when** you need hard data isolation between tenants — where a missed `WHERE` clause can't accidentally leak data across tenants. This is common in regulated industries, B2B SaaS with contractual isolation requirements, or when retrofitting an existing single-tenant app. -**NOTE: when switching tenants per-request, keep in mind that the order of your Rack middleware is important.** -See the [Middleware Considerations](#middleware-considerations) section for more. +**Consider row-level tenancy instead** if you have many tenants (hundreds+), need cross-tenant queries, or are starting a greenfield project. Row-level is simpler, uses fewer database resources, and scales more linearly. See the [Arkency comparison](https://blog.arkency.com/comparison-of-approaches-to-multitenancy-in-rails-apps/) for a thorough analysis. -The initializer above will generate the appropriate code for the Subdomain elevator -by default. You can see this in `config/initializers/apartment.rb` after running -that generator. If you're *not* using the generator, you can specify your -elevator below. Note that in this case you will **need** to require the elevator -manually in your `application.rb` like so +## About ros-apartment -```ruby -# config/application.rb -require 'apartment/elevators/subdomain' # or 'domain', 'first_subdomain', 'host' -``` +This gem is a maintained fork of the original [Apartment gem](https://github.com/influitive/apartment). Maintained by [CampusESP](https://www.campusesp.com) since 2024. Same `require 'apartment'`; v4 introduces a pool-per-tenant architecture that replaces the thread-local switching of v3. Tenant context is fiber-safe via `CurrentAttributes`, and connection pools are managed per tenant rather than swapping search paths on a shared connection. See the [upgrade guide](docs/upgrading-to-v4.md) for migration steps from v3. -#### Switch on subdomain +## Installation -In house, we use the subdomain elevator, which analyzes the subdomain of the request and switches to a tenant schema of the same name. It can be used like so: +### Requirements -```ruby -# application.rb -module MyApplication - class Application < Rails::Application - config.middleware.use Apartment::Elevators::Subdomain - end -end -``` +- Ruby 3.3+ +- Rails 7.2+ +- PostgreSQL 14+, MySQL 8.4+, or SQLite3 -If you want to exclude a domain, for example if you don't want your application to treat www like a subdomain, in an initializer in your application, you can set the following: +### Setup ```ruby -# config/initializers/apartment/subdomain_exclusions.rb -Apartment::Elevators::Subdomain.excluded_subdomains = ['www'] +# Gemfile +gem 'ros-apartment', require: 'apartment' ``` -This functions much in the same way as Apartment.excluded_models. This example will prevent switching your tenant when the subdomain is www. Handy for subdomains like: "public", "www", and "admin" :) +```bash +bundle install +bundle exec rails generate apartment:install +``` -#### Switch on first subdomain +## Quick Start -To switch on the first subdomain, which analyzes the chain of subdomains of the request and switches to a tenant schema of the first name in the chain (e.g. owls.birds.animals.com would switch to "owls"). It can be used like so: +The generated initializer at `config/initializers/apartment.rb` configures Apartment: ```ruby -# application.rb -module MyApplication - class Application < Rails::Application - config.middleware.use Apartment::Elevators::FirstSubdomain - end +Apartment.configure do |config| + config.tenant_strategy = :schema # :schema (PostgreSQL) or :database_name (MySQL/SQLite) + config.tenants_provider = -> { Customer.pluck(:subdomain) } + config.default_tenant = 'public' end ``` -If you want to exclude a domain, for example if you don't want your application to treat www like a subdomain, in an initializer in your application, you can set the following: +Tenant context is block-scoped. Always use `Apartment::Tenant.switch` with a block in application code; it guarantees cleanup on exceptions. ```ruby -# config/initializers/apartment/subdomain_exclusions.rb -Apartment::Elevators::FirstSubdomain.excluded_subdomains = ['www'] -``` +Apartment::Tenant.create('acme') -This functions much in the same way as the Subdomain elevator. **NOTE:** in fact, at the time of this writing, the `Subdomain` and `FirstSubdomain` elevators both use the first subdomain ([#339](https://github.com/influitive/apartment/issues/339#issuecomment-235578610)). If you need to switch on larger parts of a Subdomain, consider using a Custom Elevator. - -#### Switch on domain - -To switch based on full domain (excluding the 'www' subdomains and top level domains *ie '.com'* ) use the following: - -```ruby -# application.rb -module MyApplication - class Application < Rails::Application - config.middleware.use Apartment::Elevators::Domain - end +Apartment::Tenant.switch('acme') do + User.create!(name: 'Alice') # in the 'acme' schema end -``` - -Note that if you have several subdomains, then it will match on the first *non-www* subdomain: -- example.com => example -- www.example.com => example -- a.example.com => a - -#### Switch on full host using a hash - -To switch based on full host with a hash to find corresponding tenant name use the following: -```ruby -# application.rb -module MyApplication - class Application < Rails::Application - config.middleware.use Apartment::Elevators::HostHash, {'example.com' => 'example_tenant'} - end -end +Apartment::Tenant.drop('acme') ``` -#### Switch on full host, ignoring given first subdomains +`switch!` exists for console/REPL use but is discouraged in application code. -To switch based on full host to find corresponding tenant name use the following: +Global models that live outside tenant schemas use `pin_tenant`: ```ruby -# application.rb -module MyApplication - class Application < Rails::Application - config.middleware.use Apartment::Elevators::Host - end +class Company < ApplicationRecord + include Apartment::Model + pin_tenant # always queries the default (public) schema end ``` -If you want to exclude a first-subdomain, for example if you don't want your application to include www in the matching, in an initializer in your application, you can set the following: +## Configuration Reference -```ruby -Apartment::Elevators::Host.ignored_first_subdomains = ['www'] -``` +All options are set in `config/initializers/apartment.rb` inside an `Apartment.configure` block. -With the above set, these would be the results: -- example.com => example.com -- www.example.com => example.com -- a.example.com => a.example.com -- www.a.example.com => a.example.com +### Required Options -#### Custom Elevator +`tenant_strategy`: the isolation method. `:schema` for PostgreSQL schema-per-tenant, `:database_name` for MySQL/SQLite database-per-tenant. -A Generic Elevator exists that allows you to pass a `Proc` (or anything that responds to `call`) to the middleware. This Object will be passed in an `ActionDispatch::Request` object when called for you to do your magic. Apartment will use the return value of this proc to switch to the appropriate tenant. Use like so: +`tenants_provider`: a callable that returns tenant names. Called at migration time and by rake tasks. Example: `-> { Customer.pluck(:subdomain) }`. -```ruby -# application.rb -module MyApplication - class Application < Rails::Application - # Obviously not a contrived example - config.middleware.use Apartment::Elevators::Generic, proc { |request| request.host.reverse } - end -end -``` +### Pool Settings -Your other option is to subclass the Generic elevator and implement your own -switching mechanism. This is exactly how the other elevators work. Look at -the `subdomain.rb` elevator to get an idea of how this should work. Basically -all you need to do is subclass the generic elevator and implement your own -`parse_tenant_name` method that will ultimately return the name of the tenant -based on the request being made. It *could* look something like this: +`tenant_pool_size`: connections per tenant pool (default: 5). -```ruby -# app/middleware/my_custom_elevator.rb -class MyCustomElevator < Apartment::Elevators::Generic +`pool_idle_timeout`: seconds before an idle tenant pool is eligible for reaping (default: 300). - # @return {String} - The tenant to switch to - def parse_tenant_name(request) - # request is an instance of Rack::Request +`max_total_connections`: hard cap across all tenant pools; nil for unlimited (default: nil). - # example: look up some tenant from the db based on this request - tenant_name = SomeModel.from_request(request) - - return tenant_name - end -end -``` - -#### Middleware Considerations - -In the examples above, we show the Apartment middleware being appended to the Rack stack with +### Elevator (Request Tenant Detection) ```ruby -Rails.application.config.middleware.use Apartment::Elevators::Subdomain +config.elevator = :subdomain +config.elevator_options = {} ``` -By default, the Subdomain middleware switches into a Tenant based on the subdomain at the beginning of the request, and when the request is finished, it switches back to the "public" Tenant. This happens in the [Generic](https://github.com/influitive/apartment/blob/development/lib/apartment/elevators/generic.rb#L22) elevator, so all elevators that inherit from this elevator will operate as such. +The Railtie auto-inserts elevator middleware. No manual `config.middleware.use` needed. -It's also good to note that Apartment switches back to the "public" tenant any time an error is raised in your application. +See the [Elevators](#elevators) section for available options. -This works okay for simple applications, but it's important to consider that you may want to maintain the "selected" tenant through different parts of the Rack application stack. For example, the [Devise](https://github.com/plataformatec/devise) gem adds the `Warden::Manager` middleware at the end of the stack in the examples above, our `Apartment::Elevators::Subdomain` middleware would come after it. Trouble is, Apartment resets the selected tenant after the request is finish, so some redirects (e.g. authentication) in Devise will be run in the context of the "public" tenant. The same issue would also effect a gem such as the [better_errors](https://github.com/charliesome/better_errors) gem which inserts a middleware quite early in the Rails middleware stack. +### Migrations -To resolve this issue, consider adding the Apartment middleware at a location in the Rack stack that makes sense for your needs, e.g.: +`parallel_migration_threads`: number of threads for parallel tenant migration; 0 for sequential (default: 0). -```ruby -Rails.application.config.middleware.insert_before Warden::Manager, Apartment::Elevators::Subdomain -``` +`schema_load_strategy`: how to initialize new tenant schemas on create. `nil` (no schema loading), `:schema_rb`, or `:sql` (default: nil). -Now work done in the Warden middleware is wrapped in the `Apartment::Tenant.switch` context started in the Generic elevator. +`seed_after_create`: run seeds after tenant creation (default: false). -### Dropping Tenants +`seed_data_file`: path to a custom seeds file; uses `db/seeds.rb` when nil (default: nil). -To drop tenants using Apartment, use the following command: +`schema_file`: path to a custom schema file for tenant creation (default: nil). -```ruby -Apartment::Tenant.drop('tenant_name') -``` +`check_pending_migrations`: raise `PendingMigrationError` in local environments when a tenant has unapplied migrations (default: true). -When method is called, the schema is dropped and all data from itself will be lost. Be careful with this method. +### Advanced -### Custom Prompt +`schema_cache_per_tenant`: load per-tenant schema cache files when establishing tenant pools (default: false). -#### Console methods +`active_record_log`: tag Rails log output with the current tenant using `ActiveSupport::TaggedLogging`. Log lines inside a `switch` block are tagged with `tenant=name`; nested switches stack tags (`[tenant=acme] [tenant=widgets]`). Requires `Rails.logger` to respond to `tagged` (default: false). -`ros-apartment` console configures two helper methods: -1. `tenant_list` - list available tenants while using the console -2. `st(tenant_name:String)` - Switches the context to the tenant name passed, if -it exists. +`sql_query_tags`: add a `tenant` tag to `ActiveRecord::QueryLogs` so SQL queries include a `/* tenant='name' */` comment. Visible in slow query logs, `pg_stat_activity`, and database monitoring tools (default: false). -#### Custom printed prompt +`shard_key_prefix`: prefix for ActiveRecord shard keys used in tenant pool registration (default: `'apartment'`). Must match `/[a-z_][a-z0-9_]*/`. -`ros-apartment` also has a custom prompt that gives a bit more information about -the context in which you're running. It shows the environment as well as the tenant -that is currently switched to. In order for you to enable this, you need to require -the custom console in your application. +### Tenant Naming -In `application.rb` add `require 'apartment/custom_console'`. -Please note that we rely on `pry-rails` to edit the prompt, thus your project needs -to install it as well. In order to do so, you need to add `gem 'pry-rails'` to your -project's gemfile. +`environmentify_strategy`: how to namespace tenant names per Rails environment. `nil` (no prefix), `:prepend`, `:append`, or a callable (default: nil). -## Config +### RBAC -The following config options should be set up in a Rails initializer such as: +`migration_role`: a Symbol naming the database role used for migrations (default: nil, uses the connection's default role). - config/initializers/apartment.rb +`app_role`: a String or callable returning the restricted role for application queries (default: nil). -To set config options, add this to your initializer: +### PostgreSQL ```ruby Apartment.configure do |config| - # set your options (described below) here + config.configure_postgres do |pg| + pg.persistent_schemas = ['shared_extensions'] + end end ``` -### Skip tenant schema check - -This is configurable by setting: `tenant_presence_check`. It defaults to true -in order to maintain the original gem behavior. This is only checked when using one of the PostgreSQL adapters. -The original gem behavior, when running `switch` would look for the existence of the schema before switching. This adds an extra query on every context switch. While in the default simple scenarios this is a valid check, in high volume platforms this adds some unnecessary overhead which can be detected in some other ways on the application level. - -Setting this configuration value to `false` will disable the schema presence check before trying to switch the context. +PostgreSQL extensions (hstore, uuid-ossp, etc.) should be installed in a persistent schema so they're accessible from all tenant schemas: ```ruby -Apartment.configure do |config| - tenant_presence_check = false +# lib/tasks/db_enhancements.rake +namespace :db do + task extensions: :environment do + ActiveRecord::Base.connection.execute('CREATE SCHEMA IF NOT EXISTS shared_extensions;') + ActiveRecord::Base.connection.execute('CREATE EXTENSION IF NOT EXISTS HSTORE SCHEMA shared_extensions;') + ActiveRecord::Base.connection.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA shared_extensions;') + end end -``` - -### Additional logging information - -Enabling this configuration will output the database that the process is currently connected to as well as which -schemas are in the search path. This can be enabled by setting to true the `active_record_log` configuration. - -Please note that our custom logger inherits from `ActiveRecord::LogSubscriber` so this will be required for the configuration to work. - -**Example log output:** - - -```ruby -Apartment.configure do |config| - config.active_record_log = true -end +Rake::Task['db:create'].enhance { Rake::Task['db:extensions'].invoke } +Rake::Task['db:test:purge'].enhance { Rake::Task['db:extensions'].invoke } ``` -### Excluding models - -If you have some models that should always access the 'public' tenant, you can specify this by configuring Apartment using `Apartment.configure`. This will yield a config object for you. You can set excluded models like so: +Ensure your `database.yml` includes the persistent schema: -```ruby -config.excluded_models = ["User", "Company"] # these models will not be multi-tenanted, but remain in the global (public) namespace +```yaml +schema_search_path: "public,shared_extensions" ``` -Note that a string representation of the model name is now the standard so that models are properly constantized when reloaded in development - -Rails will always access the 'public' tenant when accessing these models, but note that tables will be created in all schemas. This may not be ideal, but its done this way because otherwise rails wouldn't be able to properly generate the schema.rb file. - -> **NOTE - Many-To-Many Excluded Models:** -> Since model exclusions must come from referencing a real ActiveRecord model, `has_and_belongs_to_many` is NOT supported. In order to achieve a many-to-many relationship for excluded models, you MUST use `has_many :through`. This way you can reference the join model in the excluded models configuration. +Additional PostgreSQL options (set inside the `configure_postgres` block): -### Postgresql Schemas +`include_schemas_in_dump`: non-public schemas to include in schema dumps, e.g., `%w[ext shared]` (default: []). -#### Alternative: Creating new schemas by using raw SQL dumps - -Apartment can be forced to use raw SQL dumps insted of `schema.rb` for creating new schemas. Use this when you are using some extra features in postgres that can't be represented in `schema.rb`, like materialized views etc. - -This only applies while using postgres adapter and `config.use_schemas` is set to `true`. -(Note: this option doesn't use `db/structure.sql`, it creates SQL dump by executing `pg_dump`) - -Enable this option with: +### MySQL ```ruby -config.use_sql = true +Apartment.configure do |config| + config.configure_mysql do |my| + # MySQL-specific options + end +end ``` -### Providing a Different default_tenant +## Elevators -By default, ActiveRecord will use `"$user", public` as the default `schema_search_path`. This can be modified if you wish to use a different default schema be setting: +Elevators are Rack middleware that detect the tenant from the incoming request and call `Apartment::Tenant.switch` for the duration of that request. -```ruby -config.default_tenant = "some_other_schema" -``` +Available elevators: -With that set, all excluded models will use this schema as the table name prefix instead of `public` and `reset` on `Apartment::Tenant` will return to this schema as well. +- Subdomain: `acme.example.com` -> `'acme'` +- Domain: `acme.com` -> `'acme'` +- Host: full hostname matching +- HostHash: `{ 'acme.com' => 'acme_tenant' }` +- FirstSubdomain: first subdomain in a multi-level chain +- Header: tenant name from an HTTP header (new in v4) -### Persistent Schemas - -Apartment will normally just switch the `schema_search_path` whole hog to the one passed in. This can lead to problems if you want other schemas to always be searched as well. Enter `persistent_schemas`. You can configure a list of other schemas that will always remain in the search path, while the default gets swapped out: +Configuration via `config.elevator`: ```ruby -config.persistent_schemas = ['some', 'other', 'schemas'] +Apartment.configure do |config| + config.elevator = :subdomain +end ``` -### Installing Extensions into Persistent Schemas - -Persistent Schemas have numerous useful applications. [Hstore](http://www.postgresql.org/docs/9.1/static/hstore.html), for instance, is a popular storage engine for Postgresql. In order to use extensions such as Hstore, you have to install it to a specific schema and have that always in the `schema_search_path`. +The Railtie inserts the elevator as middleware automatically. You do not need `config.middleware.use` or `config.middleware.insert_before`. -When using extensions, keep in mind: -* Extensions can only be installed into one schema per database, so we will want to install it into a schema that is always available in the `schema_search_path` -* The schema and extension need to be created in the database *before* they are referenced in migrations, database.yml or apartment. -* There does not seem to be a way to create the schema and extension using standard rails migrations. -* Rails db:test:prepare deletes and recreates the database, so it needs to be easy for the extension schema to be recreated here. - -#### 1. Ensure the extensions schema is created when the database is created +### Custom Elevator ```ruby -# lib/tasks/db_enhancements.rake - -####### Important information #################### -# This file is used to setup a shared extensions # -# within a dedicated schema. This gives us the # -# advantage of only needing to enable extensions # -# in one place. # -# # -# This task should be run AFTER db:create but # -# BEFORE db:migrate. # -################################################## - -namespace :db do - desc 'Also create shared_extensions Schema' - task :extensions => :environment do - # Create Schema - ActiveRecord::Base.connection.execute 'CREATE SCHEMA IF NOT EXISTS shared_extensions;' - # Enable Hstore - ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS HSTORE SCHEMA shared_extensions;' - # Enable UUID-OSSP - ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA shared_extensions;' - # Grant usage to public - ActiveRecord::Base.connection.execute 'GRANT usage ON SCHEMA shared_extensions to public;' +class MyElevator < Apartment::Elevators::Generic + def parse_tenant_name(request) + request.host.split('.').first end end - -Rake::Task["db:create"].enhance do - Rake::Task["db:extensions"].invoke -end - -Rake::Task["db:test:purge"].enhance do - Rake::Task["db:extensions"].invoke -end ``` -#### 2. Ensure the schema is in Rails' default connection +Then pass the class directly: -Next, your `database.yml` file must mimic what you've set for your default and persistent schemas in Apartment. When you run migrations with Rails, it won't know about the extensions schema because Apartment isn't injected into the default connection, it's done on a per-request basis, therefore Rails doesn't know about `hstore` or `uuid-ossp` during migrations. To do so, add the following to your `database.yml` for all environments - -```yaml -# database.yml -... -adapter: postgresql -schema_search_path: "public,shared_extensions" -... +```ruby +config.elevator = MyElevator ``` -This would be for a config with `default_tenant` set to `public` and `persistent_schemas` set to `['shared_extensions']`. **Note**: This only works on Heroku with [Rails 4.1+](https://devcenter.heroku.com/changelog-items/426). For apps that use older Rails versions hosted on Heroku, the only way to properly setup is to start with a fresh PostgreSQL instance: - -1. Append `?schema_search_path=public,hstore` to your `DATABASE_URL` environment variable, by this you don't have to revise the `database.yml` file (which is impossible since Heroku regenerates a completely different and immutable `database.yml` of its own on each deploy) -2. Run `heroku pg:psql` from your command line -3. And then `DROP EXTENSION hstore;` (**Note:** This will drop all columns that use `hstore` type, so proceed with caution; only do this with a fresh PostgreSQL instance) -4. Next: `CREATE SCHEMA IF NOT EXISTS hstore;` -5. Finally: `CREATE EXTENSION IF NOT EXISTS hstore SCHEMA hstore;` and hit enter (`\q` to exit) +## Pinned Models (Global Tables) -To double check, login to the console of your Heroku app and see if `Apartment.connection.schema_search_path` is `public,hstore` - -#### 3. Ensure the schema is in the apartment config +Models that belong to all tenants (users, companies, plans) are pinned to the default schema: ```ruby -# config/initializers/apartment.rb -... -config.persistent_schemas = ['shared_extensions'] -... +class User < ApplicationRecord + include Apartment::Model + pin_tenant +end ``` -#### Alternative: Creating schema by default +Why `pin_tenant`: -Another way that we've successfully configured hstore for our applications is to add it into the -postgresql template1 database so that every tenant that gets created has it by default. +- Declarative: the model declares its own tenancy, not a distant config list +- Zeitwerk-safe: no string-to-class resolution at boot time +- Composable: works with `connected_to(role: :reading)` for read replicas -One caveat with this approach is that it can interfere with other projects in development using the same extensions and template, but not using apartment with this approach. +Use `has_many :through` for associations between pinned and tenant models. `has_and_belongs_to_many` is not supported across schemas. -You can do so using a command like so +Pinned models work correctly inside `connected_to(role: :reading)` blocks. The pin bypasses Apartment's tenant routing; Rails' own role routing takes over. -```bash -psql -U postgres -d template1 -c "CREATE SCHEMA shared_extensions AUTHORIZATION some_username;" -psql -U postgres -d template1 -c "CREATE EXTENSION IF NOT EXISTS hstore SCHEMA shared_extensions;" -``` +For the edge case of models using `connects_to` with a separate database, see [Known Limitations](#known-limitations). -The *ideal* setup would actually be to install `hstore` into the `public` schema and leave the public -schema in the `search_path` at all times. We won't be able to do this though until public doesn't -also contain the tenanted tables, which is an open issue with no real milestone to be completed. -Happy to accept PR's on the matter. - -### Managing Migrations +## Callbacks -In order to migrate all of your tenants (or postgresql schemas) you need to provide a list -of dbs to Apartment. You can make this dynamic by providing a Proc object to be called on migrations. -This object should yield an array of string representing each tenant name. Example: +Hook into tenant lifecycle events: ```ruby -# Dynamically get tenant names to migrate -config.tenant_names = lambda{ Customer.pluck(:tenant_name) } - -# Use a static list of tenant names for migrate -config.tenant_names = ['tenant1', 'tenant2'] -``` - -You can then migrate your tenants using the normal rake task: +Apartment::Adapters::AbstractAdapter.set_callback :create, :after do |adapter| + # runs after a new tenant is created +end -```ruby -rake db:migrate +Apartment::Adapters::AbstractAdapter.set_callback :switch, :before do |adapter| + # runs before switching tenants +end ``` -This just invokes `Apartment::Tenant.migrate(#{tenant_name})` for each tenant name supplied -from `Apartment.tenant_names` - -Note that you can disable the default migrating of all tenants with `db:migrate` by setting -`Apartment.db_migrate_tenants = false` in your `Rakefile`. Note this must be done -*before* the rake tasks are loaded. ie. before `YourApp::Application.load_tasks` is called +## Migrations -#### Parallel Migrations +Rake tasks: -Apartment supports parallelizing migrations into multiple threads when -you have a large number of tenants. By default, parallel migrations is -turned off. You can enable this by setting `parallel_migration_threads` to -the number of threads you want to use in your initializer. +- `apartment:create`: create all tenants from `tenants_provider` +- `apartment:drop`: drop all tenants +- `apartment:migrate`: run pending migrations on all tenants +- `apartment:seed`: seed all tenants +- `apartment:rollback`: rollback last migration on all tenants -Keep in mind that because migrations are going to access the database, -the number of threads indicated here should be less than the pool size -that Rails will use to connect to your database. +The Railtie hooks the primary `db:migrate` task (when defined) so that tenant migrations run after the primary database migrates. -### Handling Environments +### Parallel Migrations -By default, when not using postgresql schemas, Apartment will prepend the environment to the tenant name -to ensure there is no conflict between your environments. This is mainly for the benefit of your development -and test environments. If you wish to turn this option off in production, you could do something like: +For applications with many schemas: ```ruby -config.prepend_environment = !Rails.env.production? +config.parallel_migration_threads = 4 # 0 = sequential (default) ``` -## Tenants on different servers +Platform notes: parallel migrations use threads. On macOS, libpq has known fork-safety issues, so threads are preferred over processes. Parallel migrations disable PostgreSQL advisory locks; ensure your migrations are safe to run concurrently. -You can store your tenants in different databases on one or more servers. -To do it, specify your `tenant_names` as a hash, keys being the actual tenant names, -values being a hash with the database configuration to use. +## Known Limitations -Example: +### `connects_to` with Separate Databases -```ruby -config.with_multi_server_setup = true -config.tenant_names = { - 'tenant1' => { - adapter: 'postgresql', - host: 'some_server', - port: 5555, - database: 'postgres' # this is not the name of the tenant's db - # but the name of the database to connect to, before creating the tenant's db - # mandatory in postgresql - } -} -# or using a lambda: -config.tenant_names = lambda do - Tenant.all.each_with_object({}) do |tenant, hash| - hash[tenant.name] = tenant.db_configuration - end -end -``` - -## Background workers - -Both these gems have been forked as a side consequence of having a new gem name. -You can use them exactly as you were using before. They are, just like this one -a drop-in replacement. +If a model (or its abstract base class) uses `connects_to` to point at a completely different database (not just different roles on the same DB), Apartment's `connection_pool` patch will attempt to create a tenant pool for it. -See [apartment-sidekiq](https://github.com/rails-on-services/apartment-sidekiq) -or [apartment-activejob](https://github.com/rails-on-services/apartment-activejob). - -## Callbacks +Workaround: add `include Apartment::Model` and `pin_tenant` on the abstract class or model that declares `connects_to` to a separate database. -You can execute callbacks when switching between tenants or creating a new one, Apartment provides the following callbacks: +The common pattern of `ApplicationRecord` using `connects_to` with multiple roles (writing/reading) on the same database works correctly; Apartment keys pools by `tenant:role` and respects Rails' role routing. -- before_create -- after_create -- before_switch -- after_switch +## Background Workers -You can register a callback using [ActiveSupport::Callbacks](https://api.rubyonrails.org/classes/ActiveSupport/Callbacks.html) the following way: +Use block-scoped switching in jobs: ```ruby -require 'apartment/adapters/abstract_adapter' - -module Apartment - module Adapters - class AbstractAdapter - set_callback :switch, :before do |object| - ... - end +class TenantJob < ApplicationJob + def perform(tenant, data) + Apartment::Tenant.switch(tenant) do + # process job end end end ``` -## Running rails console without a connection to the database +For automatic tenant propagation: -By default, once apartment starts, it establishes a connection to the database. It is possible to -disable this initial connection, by running with `APARTMENT_DISABLE_INIT` set to something: +- [apartment-sidekiq](https://github.com/rails-on-services/apartment-sidekiq) +- [apartment-activejob](https://github.com/rails-on-services/apartment-activejob) -```shell -$ APARTMENT_DISABLE_INIT=true DATABASE_URL=postgresql://localhost:1234/buk_development bin/rails runner 'puts 1' -# 1 -``` - -## Contributing +## Troubleshooting -* In both `spec/dummy/config` and `spec/config`, you will see `database.yml.sample` files - * Copy them into the same directory but with the name `database.yml` - * Edit them to fit your own settings -* Rake tasks (see the Rakefile) will help you setup your dbs necessary to run tests -* Please issue pull requests to the `development` branch. All development happens here, master is used for releases. -* Ensure that your code is accompanied with tests. No code will be merged without tests +If tenant switching raises unexpected errors, verify that `tenants_provider` returns valid tenant names and that the tenant exists in the database. -* If you're looking to help, check out the TODO file for some upcoming changes I'd like to implement in Apartment. +## Upgrading from v3 -### Running bundle install +See the [upgrade guide](docs/upgrading-to-v4.md) for a complete list of breaking changes and migration steps. -mysql2 gem in some cases fails to install. -If you face problems running bundle install in OSX, try installing the gem running: +## Contributing -`gem install mysql2 -v '0.5.3' -- --with-ldflags=-L/usr/local/opt/openssl/lib --with-cppflags=-I/usr/local/opt/openssl/include` +1. Check [existing issues](https://github.com/rails-on-services/apartment/issues) and [discussions](https://github.com/rails-on-services/apartment/discussions) +2. Fork and create a feature branch +3. Write tests: we don't merge without them +4. Run `bundle exec rspec spec/unit/` and `bundle exec rubocop` +5. Use [Appraisal](https://github.com/thoughtbot/appraisal) to test across Rails versions: `bundle exec appraisal rspec spec/unit/` +6. Submit PR to the `main` branch ## License -Apartment is released under the [MIT License](http://www.opensource.org/licenses/MIT). +[MIT License](http://www.opensource.org/licenses/MIT) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..b17d6c1a --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,165 @@ +# Releasing ros-apartment + +This document describes the release process for the `ros-apartment` gem. + +## Branch Strategy + +Following the Rails convention: + +| Branch | Purpose | +|--------|---------| +| `main` | Active development (PRs land here) | +| `4-0-stable` | v4 release branch (created when v4.0.0 ships) | +| `3-4-stable` | v3 maintenance releases | + +Features go to `main`. When a release is ready, a stable branch is created (or updated) from `main`, the version is bumped there, and a tag triggers the publish workflow. + +## Overview + +Releases are automated via GitHub Actions. Pushing a `v*` tag triggers the `gem-publish.yml` workflow, which publishes to RubyGems using trusted publishing (no API key required). + +## Prerequisites + +- CI passing on the stable branch +- Version number updated in `lib/apartment/version.rb` + +## Release Steps + +### 1. Create or update the stable branch + +For a new minor/major release, create the stable branch from `main`: + +```bash +git checkout main +git pull origin main +git checkout -b 4-0-stable # or 4-1-stable, etc. +``` + +For a patch release, work directly on the existing stable branch: + +```bash +git checkout 4-0-stable +git pull origin 4-0-stable +git cherry-pick # backport fixes from main +``` + +### 2. Bump the version + +Update `lib/apartment/version.rb` on the stable branch: + +```ruby +module Apartment + VERSION = 'X.Y.Z' +end +``` + +Follow [Semantic Versioning](https://semver.org/): +- MAJOR (X): Breaking changes +- MINOR (Y): New features, backwards compatible +- PATCH (Z): Bug fixes, backwards compatible + +Commit the version bump: + +```bash +git add lib/apartment/version.rb +git commit -m "Bump version to X.Y.Z" +git push origin 4-0-stable +``` + +### 3. Tag and publish + +```bash +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +The tag push triggers `gem-publish.yml`, which builds and publishes the gem. The `production` environment protection provides a safeguard against accidental publishes. + +### 4. Verify the publish + +Monitor the `gem-publish.yml` workflow run. Verify at: https://rubygems.org/gems/ros-apartment + +### 5. Create GitHub Release + +1. Go to https://github.com/rails-on-services/apartment/releases/new +2. Select the `vX.Y.Z` tag +3. Click "Generate release notes" for a starting point +4. Edit the release notes to highlight key changes +5. Publish the release + +We use GitHub Releases as our changelog (no CHANGELOG.md file). + +### 6. Backport the version bump + +Cherry-pick the version bump commit back to `main` so the version file stays current: + +```bash +git checkout main +git cherry-pick +git push origin main +``` + +## v3 Maintenance Releases + +The `3-4-stable` branch holds v3 maintenance code. The process is the same as above: + +1. Cherry-pick or apply fixes to `3-4-stable` +2. Bump version (e.g., `3.4.2`) +3. Tag and push: `git tag v3.4.2 && git push origin v3.4.2` +4. Create a GitHub Release noting it as a maintenance release + +Do not merge `3-4-stable` into `main`; they contain different major versions. + +### Version coordination + +- v4 uses `4.x.y` version numbers +- v3 maintenance uses `3.4.x` version numbers +- Both publish to the same `ros-apartment` gem on RubyGems +- RubyGems resolves via version constraints in user Gemfiles + +### End of v3 support + +When v3 maintenance ends, delete the `3-4-stable` branch and remove this section. + +## Workflow Details + +The `gem-publish.yml` workflow uses: +- Trusted publishing: Configured via RubyGems.org OIDC, no API key needed +- `rubygems/release-gem@v1`: Official RubyGems action +- Triggers on any `v*` tag push + +## Troubleshooting + +### Workflow fails with "tag already exists" + +Delete the tag and re-push: + +```bash +git push origin --delete vX.Y.Z +git tag -d vX.Y.Z +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +### Gem published but GitHub Release missing + +The GitHub Release is created manually (step 5). The gem is already available on RubyGems; the release is for documentation. + +### RubyGems trusted publishing fails + +Verify the GitHub environment `production` is configured correctly in repository settings, and that RubyGems.org has the trusted publisher configured for this repository. + +## Branch Migration (from pre-v4 layout) + +The repository previously used `development` as the primary branch and `main` as the release branch. The migration to the Rails-style layout: + +1. Rename `main` -> `3-4-stable` on GitHub +2. Rename `development` -> `main` on GitHub +3. Set `main` as the default branch +4. Contributors update local clones: + +```bash +git fetch --prune +git branch -m development main +git branch -u origin/main +``` diff --git a/Rakefile b/Rakefile index 1769f7d0..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 @@ -13,7 +13,7 @@ require 'appraisal' require 'rspec' require 'rspec/core/rake_task' -RSpec::Core::RakeTask.new(spec: %w[db:copy_credentials db:test:prepare]) do |spec| +RSpec::Core::RakeTask.new(spec: %w[db:load_credentials db:test:prepare]) do |spec| spec.pattern = 'spec/**/*_spec.rb' # spec.rspec_opts = '--order rand:47078' 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' @@ -37,19 +38,36 @@ task default: :spec namespace :db do namespace :test do - task prepare: %w[postgres:drop_db postgres:build_db mysql:drop_db mysql:build_db] + case ENV.fetch('DATABASE_ENGINE', nil) + when 'postgresql' + task(prepare: %w[postgres:drop_db postgres:build_db]) + when 'mysql' + task(prepare: %w[mysql:drop_db mysql:build_db]) + when 'sqlite' + task(:prepare) do + puts 'No need to prepare sqlite3 database' + end + else + task(:prepare) do + puts 'No database engine specified, skipping db:test:prepare' + end + end end desc "copy sample database credential files over if real files don't exist" - task :copy_credentials do - require 'fileutils' - apartment_db_file = 'spec/config/database.yml' - rails_db_file = 'spec/dummy/config/database.yml' + task :load_credentials do + # If no DATABASE_ENGINE is specified, we default to sqlite so that a db config is generated + db_engine = ENV.fetch('DATABASE_ENGINE', 'sqlite') - unless File.exist?(apartment_db_file) - FileUtils.copy("#{apartment_db_file}.sample", apartment_db_file, verbose: true) - end - FileUtils.copy("#{rails_db_file}.sample", rails_db_file, verbose: true) unless File.exist?(rails_db_file) + next unless db_engine && %w[postgresql mysql sqlite].include?(db_engine) + + # Load and write spec db config + db_config_string = ERB.new(File.read("spec/config/#{db_engine}.yml.erb")).result + File.write('spec/config/database.yml', db_config_string) + + # Load and write dummy app db config + db_config = YAML.safe_load(db_config_string) + File.write('spec/dummy/config/database.yml', { test: db_config['connections'][db_engine] }.to_yaml) end end @@ -67,11 +85,11 @@ namespace :postgres do params << "-p#{pg_config['port']}" if pg_config['port'] begin - `createdb #{params.join(' ')}` + system("createdb #{params.join(' ')}") rescue StandardError 'test db already exists' end - ActiveRecord::Base.establish_connection pg_config + ActiveRecord::Base.establish_connection(pg_config) migrate end @@ -83,7 +101,7 @@ namespace :postgres do params << "-U#{pg_config['username']}" params << "-h#{pg_config['host']}" if pg_config['host'] params << "-p#{pg_config['port']}" if pg_config['port'] - `dropdb #{params.join(' ')}` + system("dropdb #{params.join(' ')}") end end @@ -96,14 +114,14 @@ namespace :mysql do params = [] params << "-h #{my_config['host']}" if my_config['host'] params << "-u #{my_config['username']}" if my_config['username'] - params << "-p#{my_config['password']}" if my_config['password'] - params << "--port #{my_config['port']}" if my_config['port'] + params << "-p #{my_config['password']}" if my_config['password'] + params << "-P #{my_config['port']}" if my_config['port'] begin - `mysqladmin #{params.join(' ')} create #{my_config['database']}` + system("mysqladmin #{params.join(' ')} create #{my_config['database']}") rescue StandardError 'test db already exists' end - ActiveRecord::Base.establish_connection my_config + ActiveRecord::Base.establish_connection(my_config) migrate end @@ -113,13 +131,12 @@ namespace :mysql do params = [] params << "-h #{my_config['host']}" if my_config['host'] params << "-u #{my_config['username']}" if my_config['username'] - params << "-p#{my_config['password']}" if my_config['password'] - params << "--port #{my_config['port']}" if my_config['port'] - `mysqladmin #{params.join(' ')} drop #{my_config['database']} --force` + params << "-p #{my_config['password']}" if my_config['password'] + params << "-P #{my_config['port']}" if my_config['port'] + system("mysqladmin #{params.join(' ')} drop #{my_config['database']} --force") end end -# TODO: clean this up def config Apartment::Test.config['connections'] end @@ -132,22 +149,10 @@ def my_config config['mysql'] end -def activerecord_below_5_2? - ActiveRecord.version.release < Gem::Version.new('5.2.0') -end - -def activerecord_below_6_0? - ActiveRecord.version.release < Gem::Version.new('6.0.0') -end - def migrate - if activerecord_below_5_2? - ActiveRecord::Migrator.migrate('spec/dummy/db/migrate') - elsif activerecord_below_6_0? - ActiveRecord::MigrationContext.new('spec/dummy/db/migrate').migrate - else - # TODO: Figure out if there is any other possibility that can/should be - # passed here as the second argument for the migration context + if ActiveRecord.version.release < Gem::Version.new('7.1') ActiveRecord::MigrationContext.new('spec/dummy/db/migrate', ActiveRecord::SchemaMigration).migrate + else + ActiveRecord::MigrationContext.new('spec/dummy/db/migrate').migrate end end diff --git a/TODO.md b/TODO.md deleted file mode 100644 index a5226393..00000000 --- a/TODO.md +++ /dev/null @@ -1,50 +0,0 @@ -# Apartment TODOs - -### Below is a list of tasks in the approximate order to be completed of for Apartment -### Any help along the way is greatly appreciated (on any items, not particularly in order) - -1. Apartment was originally written (and TDD'd) with just Postgresql in mind. Different adapters were added at a later date. - As such, the test suite is a bit of a mess. There's no formal structure for fully integration testing all adapters to ensure - proper quality and prevent regressions. - - There's also a test order dependency as some tests run assuming a db connection and if that test randomly ran before a previous - one that makes the connection, it would fail. - - I'm proposing the first thing to be done is to write up a standard, high livel integration test case that can be applied to all adapters - and makes no assumptions about implementation. It should ensure that each adapter conforms to the Apartment Interface and CRUD's properly. - It would be nice if a user can 'register' an adapter such that it would automatically be tested (nice to have). Otherwise one could just use - a shared behaviour to run through all of this. - - Then, I'd like to see all of the implementation specific tests just in their own test file for each adapter (ie the postgresql schema adapter checks a lot of things with `schema_search_path`) - - This should ensure that going forward nothing breaks, and we should *ideally* be able to randomize the test order - -2. `Apartment::Database` is the wrong abstraction. When dealing with a multi-tenanted system, users shouldn't thing about 'Databases', they should - think about Tenants. I proprose that we deprecate the `Apartment::Database` constant in favour of `Apartment::Tenant` for a nicer abstraction. See - http://myronmars.to/n/dev-blog/2011/09/deprecating-constants-and-classes-in-ruby for ideas on how to achieve this. - -4. Apartment::Database.process should be deprecated in favour of just passing a block to `switch` -5. Apartment::Database.switch should be renamed to switch! to indicate that using it on its own has side effects - -6. Migrations right now can be a bit of a pain. Apartment currently migrates a single tenant completely up to date, then goes onto the next. If one of these - migrations fails on a tenant, the previous one does NOT get reverted and leaves you in an awkward state. Ideally we'd want to wrap all of the migrations in - a transaction so if one fails, the whole thing reverts. Once we can ensure an all-or-nothing approach to migrations, we can optimize the migration strategy - to not even iterate over the tenants if there are no migrations to run on public. - -7. Apartment has be come one of the most popular/robust Multi-tenant gems for Rails, but it still doesn't work for everyone's use case. It's fairly limited in implementation to either schema based (ie postgresql schemas) or connection based. I'd like to abstract out these implementation details such that one could write a pluggable strategy for Apartment and choose it based on a config selection (something like `config.strategy = :schema`). The next implementation I'd like to see is a scoped based approach that uses a `tenant_id` scoping on all records for multi-tenancy. This is probably the most popular multi-tenant approach and is db independent and really the simplest mechanism for a type of multi-tenancy. - -8. Right now excluded tables still live in all tenanted environments. This is basically because it doesn't matter if they're there, we always query from the public. - It's a bit of an annoyance though and confuses lots of people. I'd love to see only tenanted tables in the tenants and only excluded tables in the public tenant. - This will be hard because Rails uses public to generate schema.rb. One idea is to have an `excluded` schema that holds all the excluded models and the public can - maintain everything. - -9. This one is pretty lofty, but I'd also like to abstract out the fact that Apartment uses ActiveRecord. With the new DataMapper coming out soon and other popular - DBMS's (ie. mongo, couch etc...), it'd be nice if Apartment could be the de-facto interface for multi-tenancy on these systems. - - -=================== - -Quick TODOs - -2. deprecation.rb rescues everything, we have a hard dependency on ActiveSupport so this is unnecessary -3. diff --git a/bin/dev/_gum.sh b/bin/dev/_gum.sh new file mode 100755 index 00000000..445b4ab0 --- /dev/null +++ b/bin/dev/_gum.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# bin/dev/_gum.sh — Shared UX helpers for development scripts +# +# Provides logging, confirmation, and progress functions with optional +# charmbracelet/gum integration. Falls back to plain terminal output +# when gum is not installed. +# +# Usage: +# source "$(dirname "${BASH_SOURCE[0]}")/_gum.sh" +# +# Install gum for enhanced UX: +# brew install gum + +# --------------------------------------------------------------------------- +# Gum detection +# --------------------------------------------------------------------------- +if [ -z "${GUM_AVAILABLE+x}" ]; then + GUM_AVAILABLE=false + command -v gum &>/dev/null && GUM_AVAILABLE=true +fi + +# Consistent header color across all gum components (ANSI 256: dodger blue). +GUM_HEADER_COLOR=212 + +# --------------------------------------------------------------------------- +# ANSI colors +# --------------------------------------------------------------------------- +if [ -t 1 ]; then + CLR_RED='\033[0;31m' + CLR_GREEN='\033[0;32m' + CLR_YELLOW='\033[1;33m' + CLR_BLUE='\033[0;34m' + CLR_CYAN='\033[0;36m' + CLR_RESET='\033[0m' +else + CLR_RED='' + CLR_GREEN='' + CLR_YELLOW='' + CLR_BLUE='' + CLR_CYAN='' + CLR_RESET='' +fi + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +log_info() { + if $GUM_AVAILABLE; then + gum log --level info "$@" + else + echo "ℹ️ $*" >&2 + fi +} + +log_warn() { + if $GUM_AVAILABLE; then + gum log --level warn "$@" + else + echo "⚠️ $*" >&2 + fi +} + +log_error() { + if $GUM_AVAILABLE; then + gum log --level error "$@" + else + echo "❌ $*" >&2 + fi +} + +log_success() { + if $GUM_AVAILABLE; then + gum log --level info "✓ $*" + else + echo "✅ $*" >&2 + fi +} + +# --------------------------------------------------------------------------- +# Visual structure +# --------------------------------------------------------------------------- +header() { + if $GUM_AVAILABLE; then + gum style --bold --foreground "$GUM_HEADER_COLOR" --border double --border-foreground "$GUM_HEADER_COLOR" --padding "0 2" "$*" + else + echo "" + echo "══ $* ══" + echo "" + fi +} + +section() { + if $GUM_AVAILABLE; then + gum style --faint "━━━ $*" + else + echo "━━━ $*" + fi +} + +# --------------------------------------------------------------------------- +# Interaction +# --------------------------------------------------------------------------- +confirm() { + if $GUM_AVAILABLE; then + gum confirm "$1" + else + read -r -p "$1 [y/N] " REPLY + [[ "$REPLY" =~ ^[Yy]$ ]] + fi +} + +choose() { + local hdr="$1"; shift + local default_val="" + if [ "${1:-}" = "--default" ]; then + default_val="$2"; shift 2 + fi + if $GUM_AVAILABLE; then + if [ -n "$default_val" ]; then + gum choose --header "$hdr" --header.foreground "$GUM_HEADER_COLOR" --selected "$default_val" "$@" + else + gum choose --header "$hdr" --header.foreground "$GUM_HEADER_COLOR" "$@" + fi + else + local i=1 + echo "$hdr" >&2 + for item in "$@"; do + if [ "$item" = "$default_val" ]; then + echo " $i) $item (default)" >&2 + else + echo " $i) $item" >&2 + fi + i=$((i + 1)) + done + if [ -n "$default_val" ]; then + read -r -p "Choice [1-$#, Enter=default]: " REPLY + else + read -r -p "Choice [1-$#]: " REPLY + fi + if [ -z "$REPLY" ] && [ -n "$default_val" ]; then + echo "$default_val" + return 0 + fi + local j=1 + for item in "$@"; do + if [ "$j" = "$REPLY" ]; then + echo "$item" + return 0 + fi + j=$((j + 1)) + done + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Progress +# --------------------------------------------------------------------------- +spin() { + local title="$1"; shift + if $GUM_AVAILABLE; then + gum spin --spinner dot --show-error --title "$title" -- "$@" + else + echo "$title" + "$@" + fi +} diff --git a/bin/dev/remove-worktree b/bin/dev/remove-worktree new file mode 100755 index 00000000..0f70783e --- /dev/null +++ b/bin/dev/remove-worktree @@ -0,0 +1,208 @@ +#!/bin/bash +# Remove a git worktree and clean up all associated files (apartment gem). +# macOS-only (consistent with setup-worktree). + +set -e + +source "$(dirname "${BASH_SOURCE[0]}")/_gum.sh" + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +WORKTREE_PATH="" +DELETE_BRANCH=false +CONFIRM=false + +for arg in "$@"; do + case $arg in + --delete-branch) + DELETE_BRANCH=true + ;; + --confirm) + CONFIRM=true + ;; + --help|-h) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --delete-branch Also delete the local branch" + echo " --confirm Skip confirmation prompts" + echo "" + echo "Examples:" + echo " $0 v4-foundation # by name" + echo " $0 ../apartment-worktrees/v4-foundation # by path" + echo " $0 v4-foundation --delete-branch # remove + delete branch" + exit 0 + ;; + -*) + log_error "Unknown option: $arg" + echo "Run with --help for usage" >&2 + exit 1 + ;; + *) + WORKTREE_PATH="$arg" + ;; + esac +done + +# --------------------------------------------------------------------------- +# Resolve worktree base from git (works even when invoked from a worktree) +# --------------------------------------------------------------------------- +MAIN_REPO=$(git worktree list --porcelain | head -1 | sed 's/^worktree //') +WORKTREE_DIR="$(dirname "$MAIN_REPO")/apartment-worktrees" + +# --------------------------------------------------------------------------- +# Interactive mode: if no worktree specified, let user pick from list +# --------------------------------------------------------------------------- +if [ -z "$WORKTREE_PATH" ]; then + if [ ! -d "$WORKTREE_DIR" ] || [ -z "$(ls -A "$WORKTREE_DIR" 2>/dev/null)" ]; then + log_error "No worktrees found in $WORKTREE_DIR" + exit 1 + fi + + # shellcheck disable=SC2046 + WORKTREE_PATH=$(choose "Select worktree to remove:" $(ls "$WORKTREE_DIR")) || exit 1 + [ -z "$WORKTREE_PATH" ] && exit 1 +fi + +# --------------------------------------------------------------------------- +# Resolve path +# --------------------------------------------------------------------------- +if [ ! -d "$WORKTREE_PATH" ]; then + RESOLVED="$WORKTREE_DIR/$WORKTREE_PATH" + if [ -d "$RESOLVED" ]; then + WORKTREE_PATH="$RESOLVED" + else + log_error "Worktree not found: $WORKTREE_PATH (also tried $RESOLVED)" + exit 1 + fi +fi + +ABS_PATH=$(cd "$WORKTREE_PATH" 2>/dev/null && pwd || echo "$WORKTREE_PATH") +WORKTREE_NAME=$(basename "$ABS_PATH") + +header "Remove worktree: $WORKTREE_NAME" + +# --------------------------------------------------------------------------- +# Detect branch (using line-based parsing to handle paths with spaces) +# --------------------------------------------------------------------------- +BRANCH="" +IS_GIT_WORKTREE=false +if git worktree list --porcelain | grep -q "^worktree ${ABS_PATH}$"; then + IS_GIT_WORKTREE=true + BRANCH=$(git worktree list --porcelain | awk -v path="worktree $ABS_PATH" ' + $0 == path { found=1; next } + found && /^branch / { sub("^branch refs/heads/", ""); print; exit } + found && /^worktree / { exit } + ') + [ -n "$BRANCH" ] && log_info "Branch: $BRANCH" +fi + +# --------------------------------------------------------------------------- +# Check for uncommitted changes +# --------------------------------------------------------------------------- +if [ "$IS_GIT_WORKTREE" = true ]; then + if ! CHANGES=$(git -C "$WORKTREE_PATH" status --porcelain 2>&1); then + log_warn "Could not check worktree status: $CHANGES" + if [ "$CONFIRM" = false ]; then + confirm "Proceed with removal anyway? (uncommitted changes may exist)" || { echo "Aborted."; exit 1; } + fi + CHANGES="" # Don't re-prompt below + fi + if [ -n "$CHANGES" ]; then + log_warn "Worktree has uncommitted changes:" + echo "" + git -C "$WORKTREE_PATH" status --short + echo "" + if [ "$CONFIRM" = false ]; then + confirm "Discard these changes and remove worktree?" || { echo "Aborted."; exit 1; } + else + log_info "Proceeding without prompt (--confirm)" + fi + fi +fi + +# --------------------------------------------------------------------------- +# Remove worktree +# --------------------------------------------------------------------------- +if [ "$IS_GIT_WORKTREE" = true ]; then + if ! git worktree remove --force "$WORKTREE_PATH" 2>&1; then + log_warn "git worktree remove failed — will clean up directory manually" + log_warn "You may need to run 'git worktree prune' afterward" + fi +fi + +# Safety check before rm -rf: only remove if it looks like a worktree +if [ -d "$WORKTREE_PATH" ]; then + if [ -f "$WORKTREE_PATH/.git" ] || [[ "$ABS_PATH" == *"/apartment-worktrees/"* ]]; then + spin "Cleaning up leftover files..." rm -rf "$WORKTREE_PATH" + else + log_error "Refusing to remove '$ABS_PATH': does not appear to be a worktree" + log_error "Expected a .git file or a path under apartment-worktrees/" + exit 1 + fi +fi + +git worktree prune + +# --------------------------------------------------------------------------- +# Color Registry cleanup +# --------------------------------------------------------------------------- +REGISTRY="${WORKTREE_COLOR_REGISTRY:-$HOME/.config/campusesp/color-registry.json}" +if ! command -v jq &>/dev/null; then + log_warn "jq not found — cannot free color from registry (brew install jq)" +elif [ ! -f "$REGISTRY" ]; then + # No registry, nothing to clean up — not a warning since it may not be configured + : +else + JQ_CHECK=$(jq -e --arg name "$WORKTREE_NAME" '.worktree_colors[$name]' "$REGISTRY" 2>&1) + JQ_EXIT=$? + if [ $JQ_EXIT -eq 0 ]; then + # Key exists — remove it + _REGISTRY_TMP=$(mktemp "${REGISTRY}.XXXXXX") + if jq --arg name "$WORKTREE_NAME" 'del(.worktree_colors[$name])' "$REGISTRY" > "$_REGISTRY_TMP" \ + && mv "$_REGISTRY_TMP" "$REGISTRY"; then + log_info "Color registry: freed color for $WORKTREE_NAME" + else + log_warn "Color registry: failed to free color for $WORKTREE_NAME" + rm -f "$_REGISTRY_TMP" + fi + elif [ $JQ_EXIT -eq 1 ]; then + : # Key not found (jq -e returns 1 for null/false) — nothing to clean + else + log_warn "Color registry: failed to read $REGISTRY" + fi +fi + +# --------------------------------------------------------------------------- +# Optionally delete the local branch +# --------------------------------------------------------------------------- +if [ -n "$BRANCH" ] && [ "$DELETE_BRANCH" = false ] && [ "$CONFIRM" = false ]; then + if git branch --list "$BRANCH" | grep -q .; then + confirm "Also delete local branch '$BRANCH'?" && DELETE_BRANCH=true || true + fi +fi + +if [ "$DELETE_BRANCH" = true ] && [ -n "$BRANCH" ]; then + if git branch --list "$BRANCH" | grep -q .; then + # Try safe delete first; escalate to force-delete with warning + if ! git branch -d "$BRANCH" 2>/dev/null; then + log_warn "Branch '$BRANCH' has unmerged commits" + if [ "$CONFIRM" = false ]; then + confirm "Force-delete branch '$BRANCH'? (unmerged commits will be lost)" \ + && git branch -D "$BRANCH" \ + && log_info "Force-deleted branch: $BRANCH" \ + || log_info "Keeping branch '$BRANCH'" + else + git branch -D "$BRANCH" + log_info "Force-deleted branch: $BRANCH" + fi + else + log_info "Deleted branch: $BRANCH" + fi + else + log_info "Branch '$BRANCH' not found locally (already deleted?)" + fi +fi + +log_success "Worktree '$WORKTREE_NAME' removed." diff --git a/bin/dev/setup-worktree b/bin/dev/setup-worktree new file mode 100755 index 00000000..4af18cb2 --- /dev/null +++ b/bin/dev/setup-worktree @@ -0,0 +1,202 @@ +#!/bin/bash +# Setup script for new Git worktrees (apartment gem) +# Copies essential untracked files and configures Peacock colors. +# macOS-only (uses ditto for APFS copy-on-write). + +set -e + +# Source shared UX helpers +_GUM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$_GUM_DIR/_gum.sh" + +# Defaults +SKIP_PEACOCK=false + +# Parse arguments +WORKTREE_PATH="" +for arg in "$@"; do + case $arg in + --no-peacock|--skip-peacock) + SKIP_PEACOCK=true + ;; + --help|-h) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --no-peacock Skip VS Code Peacock color configuration" + echo "" + echo "Example: $0 ../apartment-worktrees/v4-foundation" + exit 0 + ;; + -*) + echo "Unknown option: $arg" >&2 + echo "Run with --help for usage" >&2 + exit 1 + ;; + *) + WORKTREE_PATH="$arg" + ;; + esac +done + +if [ -z "$WORKTREE_PATH" ]; then + echo "Usage: $0 [--no-peacock]" + echo "Example: $0 ../apartment-worktrees/v4-foundation" + exit 1 +fi + +# Resolve main repo from git worktree list (works even when invoked from a worktree) +MAIN_REPO_PATH=$(git worktree list --porcelain | head -1 | sed 's/^worktree //') + +if [ ! -d "$WORKTREE_PATH" ]; then + log_error "Worktree path '$WORKTREE_PATH' does not exist" + log_info "Create the worktree first with: git worktree add $WORKTREE_PATH -b branch-name" + exit 1 +fi + +header "Worktree Setup" +log_info "Worktree: $WORKTREE_PATH" +log_info "Main repo: $MAIN_REPO_PATH" + +cd "$WORKTREE_PATH" || { log_error "Failed to cd into $WORKTREE_PATH"; exit 1; } + +# Copy files/directories using ditto (macOS-native, APFS copy-on-write) +copy_if_exists() { + local source="$MAIN_REPO_PATH/$1" + local dest="$1" + + if [ -e "$source" ]; then + log_info "Copying $1" + if ! ditto "$source" "$dest"; then + log_error "Failed to copy $1 — check permissions and disk space" + exit 1 + fi + else + log_warn "Skipping $1 (doesn't exist in main repo)" + fi +} + +section "Copying untracked files and directories" + +copy_if_exists ".claude" +copy_if_exists ".bundle" +copy_if_exists ".vscode" + +# Peacock color assignment via shared color registry +PEACOCK_COLOR="" +REGISTRY="${WORKTREE_COLOR_REGISTRY:-$HOME/.config/campusesp/color-registry.json}" + +if [ "$SKIP_PEACOCK" = false ]; then + WORKTREE_NAME=$(basename "$WORKTREE_PATH") + + # Gracefully degrade if jq or registry are missing + if ! command -v jq &>/dev/null; then + log_warn "jq not found — skipping Peacock color assignment (brew install jq)" + SKIP_PEACOCK=true + elif [ ! -f "$REGISTRY" ]; then + log_warn "Color registry not found at $REGISTRY — skipping Peacock color assignment" + SKIP_PEACOCK=true + fi +fi + +if [ "$SKIP_PEACOCK" = false ]; then + # Trap to clean up temp files on interrupt + _REGISTRY_TMP="" + _SETTINGS_TMP="" + cleanup_tmp_files() { rm -f "$_REGISTRY_TMP" "$_SETTINGS_TMP" 2>/dev/null; } + trap cleanup_tmp_files EXIT INT TERM + + EXISTING=$(jq -r --arg name "$WORKTREE_NAME" '.worktree_colors[$name] // empty' "$REGISTRY") + if [ -n "$EXISTING" ]; then + PEACOCK_COLOR="$EXISTING" + log_info "Color registry: reusing $EXISTING for $WORKTREE_NAME" + else + # Assign first palette color not used by any project or worktree + JQ_OUTPUT=$(jq -r ' + .palette as $p | + ([.projects | to_entries[].value.color] + [.worktree_colors // {} | to_entries[].value]) as $used | + ($p - $used) | first // empty + ' "$REGISTRY" 2>&1) + JQ_EXIT=$? + + if [ $JQ_EXIT -ne 0 ]; then + log_warn "Color registry: failed to parse $REGISTRY — skipping color assignment" + log_warn " jq output: $JQ_OUTPUT" + SKIP_PEACOCK=true + else + ASSIGNED_COLOR="$JQ_OUTPUT" + fi + + if [ "$SKIP_PEACOCK" = false ] && [ -z "$ASSIGNED_COLOR" ]; then + log_warn "Color registry: palette exhausted (all colors in use) — skipping" + SKIP_PEACOCK=true + fi + + if [ "$SKIP_PEACOCK" = false ]; then + _REGISTRY_TMP=$(mktemp "${REGISTRY}.XXXXXX") + if jq --arg name "$WORKTREE_NAME" --arg color "$ASSIGNED_COLOR" \ + '.worktree_colors[$name] = $color' "$REGISTRY" > "$_REGISTRY_TMP" \ + && mv "$_REGISTRY_TMP" "$REGISTRY"; then + _REGISTRY_TMP="" # Successfully moved, nothing to clean up + PEACOCK_COLOR="$ASSIGNED_COLOR" + log_info "Color registry: assigned $ASSIGNED_COLOR to $WORKTREE_NAME" + else + log_warn "Color registry: failed to write — skipping color assignment" + SKIP_PEACOCK=true + fi + fi + fi + + # Update or create .vscode/settings.json with Peacock color using jq + if [ -n "$PEACOCK_COLOR" ]; then + mkdir -p .vscode + if [ -f ".vscode/settings.json" ]; then + # VS Code settings files often contain JSONC (comments, trailing commas) + # which jq cannot parse. Detect and provide actionable guidance. + if ! jq empty .vscode/settings.json 2>/dev/null; then + log_warn ".vscode/settings.json contains invalid JSON (comments or trailing commas?)" + log_warn "Add manually: \"peacock.color\": \"$PEACOCK_COLOR\"" + else + _SETTINGS_TMP=$(mktemp ".vscode/settings.json.XXXXXX") + if jq --arg color "$PEACOCK_COLOR" '.["peacock.color"] = $color' \ + .vscode/settings.json > "$_SETTINGS_TMP" \ + && mv "$_SETTINGS_TMP" .vscode/settings.json; then + _SETTINGS_TMP="" # Successfully moved + else + log_warn "Failed to update .vscode/settings.json" + log_warn "Add manually: \"peacock.color\": \"$PEACOCK_COLOR\"" + rm -f "$_SETTINGS_TMP" + _SETTINGS_TMP="" + fi + fi + else + jq -n --arg color "$PEACOCK_COLOR" '{"peacock.color": $color}' > .vscode/settings.json + fi + fi +else + if [ "$SKIP_PEACOCK" = true ]; then + : # Already logged the reason above + else + log_info "Skipping Peacock color configuration (--no-peacock)" + fi +fi + +# Check dependencies +section "Checking dependencies" + +if [ -f "Gemfile" ]; then + log_info "Ruby gems should be available (gems are installed globally)" + log_info "Run 'bundle install' only if you add new gems to this worktree" +else + log_warn "No Gemfile found" +fi + +echo "" +log_success "Worktree setup complete!" +log_info "To work in this worktree:" +log_info " cd $WORKTREE_PATH" +if [ -n "$PEACOCK_COLOR" ]; then + log_info " code . # Open in Cursor/VSCode (Peacock: $PEACOCK_COLOR)" +else + log_info " code . # Open in Cursor/VSCode" +fi diff --git a/context7.json b/context7.json new file mode 100644 index 00000000..c75a85e7 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/rails-on-services/apartment", + "public_key": "pk_EQhqzkh8FktmxBU0mbzmZ" +} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index bddcaad6..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,33 +0,0 @@ -version: '2.3' -services: - postgresql: - image: postgres:9.5.12 - environment: - POSTGRES_PASSWORD: "" - ports: - - "5432:5432" - healthcheck: - test: pg_isready -U postgres - start_period: 10s - interval: 10s - timeout: 30s - retries: 3 - mysql: - image: mysql:5.7 - environment: - MYSQL_ALLOW_EMPTY_PASSWORD: "yes" - ports: - - "3306:3306" - healthcheck: - test: mysqladmin -h 127.0.0.1 -uroot ping - start_period: 15s - interval: 10s - timeout: 30s - retries: 3 - healthcheck: - image: busybox - depends_on: - postgresql: - condition: service_healthy - mysql: - condition: service_healthy 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/designs/apartment-v4.md b/docs/designs/apartment-v4.md new file mode 100644 index 00000000..1f014e36 --- /dev/null +++ b/docs/designs/apartment-v4.md @@ -0,0 +1,856 @@ +# Apartment v4 Design Spec + +## Overview + +Apartment v4 is a ground-up rewrite of the `ros-apartment` gem, replacing v3's thread-local `SET search_path` switching model with an immutable connection-pool-per-tenant architecture. The rewrite addresses fundamental concurrency, safety, and compatibility limitations in v3 while preserving its proven patterns (adapters, elevators, callbacks, excluded models). + +**Primary goals:** +- Eliminate tenant context leakage via immutable per-tenant connection pools +- Full thread and fiber safety via `ActiveSupport::CurrentAttributes` +- PgBouncer/RDS Proxy compatibility (reduced session pinning via connection-level config) +- Rails 7.2, 8.0, 8.1 support; Ruby 3.3+ +- Sub-millisecond tenant switching for cached pools + +**Build approach:** Fresh branch off `development`. The v4 alpha branch (`man/spec-restart`) serves as reference architecture. Production-hardened v3.3-3.4 features (parallel migrations, multi-db rake tasks, Rails 8.x compatibility) are ported and adapted. + +**What this is not:** An incremental refactor of v3. This is a clean break with a deprecation bridge in v3.5. + +## Context & Motivation + +### Problems with v3 + +1. **Thread-local tenant state** (`Thread.current[:apartment_adapter]`): Not fiber-safe, breaks with `load_async`, `ActionController::Live`, and async frameworks. (#199, #239, #304) +2. **`SET search_path` switching**: Causes session pinning in PgBouncer/RDS Proxy transaction mode, preventing connection reuse. (#302) +3. **Connection leaks under load**: `establish_connection` called per switch creates unbounded connections with multiple workers/threads. (#323) +4. **Rails 8.1 schema dump regression**: `public.` prefix in `schema.rb` breaks tenant table creation. (#341) +5. **Cross-thread state sharing**: No safe propagation of tenant context to background jobs, async queries, or streaming responses. + +### Prior art + +- **PR #327** (`man/spec-restart`): v4 alpha with pool-per-tenant, `CurrentAttributes`, `TenantConnectionDescriptor`. Mostly complete but WIP — missing rake tasks, CLI, some strategy stubs (`NotImplementedError`), and v3.3-3.4 features. +- **Discussion #312**: Community input on concurrency, fiber safety, `CurrentAttributes`, Rails connection pool integration, schema dumping. +- **37signals Writebook**: Rails 8 native multi-tenancy via `connected_to(tenant:)` with SQLite-per-tenant. Validates the direction but lacks schema-based tenancy, lifecycle management, and middleware. + +### What v4 preserves from v3 + +- Adapter pattern (PostgreSQL, MySQL, SQLite — database-specific implementations behind unified API) +- Elevator middleware (Rack-based tenant detection from request attributes) +- Callback system (`ActiveSupport::Callbacks` on `:create` and `:switch`) +- Excluded models (shared tables pinned to default tenant) +- Configuration philosophy (dynamic tenant discovery via callable, fail-fast validation) +- Parallel migration infrastructure (simplified for pool-per-tenant) +- Multi-database rake task enhancement (v3.4.1) + +## Version Requirements + +| Dependency | Minimum | Rationale | +|-----------|---------|-----------| +| Ruby | 3.3+ | 3.2 EOL April 2026 | +| Rails | 7.2+ | Aligns with Rails support policy; `migration_context` on `connection_pool` (not `connection`); no legacy connection handling shims | +| Sidekiq | No constraint | Auto-detected at boot; works on 7+ and 8+ via `CurrentAttributes` | +| PostgreSQL | 14+ | 13 and below EOL; schema-based tenancy baseline | +| MySQL | 8.4+ | 8.0 EOL April 2026; 8.4 LTS supported through 2032 | + +## Architecture + +### Core: Tenant Context via CurrentAttributes + +```ruby +# lib/apartment/current.rb +class Apartment::Current < ActiveSupport::CurrentAttributes + attribute :tenant + attribute :previous_tenant +end +``` + +Replaces `Thread.current[:apartment_adapter]`. Benefits: +- Fiber-safe (each fiber gets its own attribute store) +- Auto-reset between requests by Rails +- Natively propagated by Sidekiq 7+ and SolidQueue +- Propagated to `load_async` threads and `ActionController::Live` threads + +**Important caveat:** `CurrentAttributes` propagation to `load_async` threads depends on `config.active_support.isolation_level`. In Rails 7.2+, the default is `:fiber`, which provides proper isolation. If a user has explicitly set `isolation_level: :thread`, `load_async` spawns a new thread without propagating attributes. v4's Railtie should validate that `isolation_level` is `:fiber` (or warn if it's `:thread`) to ensure correct behavior. This is documented in the upgrade guide. + +### Core: Immutable Connection Pool Per Tenant + +Each tenant gets its own connection pool with tenant-specific config baked in at creation time. + +**PostgreSQL (schema strategy):** +```ruby +def resolve_connection_config(tenant) + base_config.merge( + schema_search_path: [tenant, *persistent_schemas].join(",") + ) +end +# Example: schema_search_path: "acme,ext,public" +``` + +**MySQL (database_name strategy):** +```ruby +def resolve_connection_config(tenant) + base_config.merge(database: tenant_database_name(tenant)) +end +``` + +No `SET search_path` at switch time. The connection *is* the tenant context. Pools are: +- **Lazily created** on first access +- **Cached** in a thread-safe `Concurrent::Map` +- **Evicted** when idle (configurable timeout, LRU) +- **Immutable** — config doesn't change after creation + +**PgBouncer/RDS Proxy compatibility:** + +v3 issues `SET search_path` on every tenant switch (per request). v4 eliminates per-switch `SET` entirely — the `schema_search_path` is baked into the connection config, so tenant switching is a pool lookup, not a SQL command. + +However, Rails' `PostgreSQLAdapter#configure_connection` still issues a one-time `SET search_path` when establishing each new connection. This means: + +- **Without PgBouncer**: No issue. Connections are long-lived; the `SET` happens once at creation. +- **With PgBouncer in session mode**: No issue. Session-pinned connections are expected. +- **With PgBouncer in transaction mode**: The initial `SET` may cause session pinning. Two mitigations: + 1. **Preferred**: Use libpq connection string `options: '-c search_path=tenant,ext,public'` which sets the search_path at the protocol level during connection establishment, avoiding a `SET` statement entirely. v4 should attempt this approach first. + 2. **Fallback**: Configure PgBouncer with `ignore_startup_parameters = search_path` or use `track_extra_parameters = search_path` (PgBouncer 1.20+, requires Citus 12+ for `GUC_REPORT` support on search_path). + 3. **Alternative**: Use `SET LOCAL search_path` inside each transaction (scoped to transaction, PgBouncer does not pin on `SET LOCAL`). This is closer to v3's approach but only executes once per transaction, not once per switch. + +The implementation should try approach (1) first and fall back to the Rails default if the database driver doesn't support connection string options. This is a significant improvement over v3 regardless — v3 issues `SET search_path` on every request; v4 issues it at most once per connection establishment. + +### Pool Resolution & Storage + +Tenant pools are managed by `Apartment::PoolManager`, which wraps a `Concurrent::Map` and integrates with ActiveRecord's `ConnectionHandler`. + +**Pool storage approach:** Pools are registered with ActiveRecord's `ConnectionHandler` using tenant-qualified connection specification names. This leverages Rails' built-in pool lifecycle (checkout, checkin, reaping, stat tracking) rather than reimplementing it. + +```ruby +# Pseudocode: pool resolution in Apartment::Patches::ConnectionHandling +module Apartment::Patches::ConnectionHandling + def connection_pool + tenant = Apartment::Current.tenant + return super if tenant.nil? || tenant == Apartment.config.default_tenant + + pool_key = "#{connection_specification_name}[#{tenant}]" + + Apartment.pool_manager.fetch_or_create(pool_key) do + config = Apartment::Tenant.adapter.resolve_connection_config(tenant) + handler = ActiveRecord::Base.connection_handler + # Register a new pool with ActiveRecord's handler using tenant-specific config + pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new( + ActiveRecord::Base, + ActiveRecord::DatabaseConfigurations::HashConfig.new( + Rails.env, pool_key, config + ), + :writing, + tenant.to_sym # Use tenant as shard identifier within AR's handler + ) + # NOTE: This pseudocode illustrates intent. The actual implementation should + # prefer public APIs (e.g., establish_connection with tenant-qualified config) + # over send(:private_method) to reduce coupling to Rails internals across versions. + handler.send(:owner_to_pool_manager, pool_key).put_pool_config(pool_config) + end + end +end +``` + +**Key design decisions:** +- `Apartment.pool_manager` uses `Concurrent::Map` for thread-safe tenant -> pool_key mapping +- Actual pool instances live inside ActiveRecord's `ConnectionHandler`, giving us free compatibility with `database_cleaner`, `strong_migrations`, and other gems that inspect `ActiveRecord::Base.connection_pool` +- When `Apartment::Current.tenant` is `nil` (e.g., during `db:migrate` with no request context), `super` is called, returning the default connection pool +- The `pool_key` format (`ClassName[tenant]`) prevents collisions with user-defined multi-db configs + +### Pool Eviction Mechanics + +Pool eviction uses a `Concurrent::TimerTask` (non-blocking periodic timer from the `concurrent-ruby` gem, already a Rails dependency): + +```ruby +# Started in Railtie after_initialize +Apartment::PoolReaper.start( + interval: Apartment.config.pool_idle_timeout, # Check interval in seconds + idle_timeout: Apartment.config.pool_idle_timeout, + max_total: Apartment.config.max_total_connections +) +``` + +**Important distinction:** `Apartment::PoolReaper` evicts entire tenant pools (inter-pool eviction). ActiveRecord's built-in `ConnectionPool::Reaper` reaps idle connections within a single pool (intra-pool reaping). These are complementary — AR's reaper handles connection-level cleanup; Apartment's handles tenant-pool-level cleanup. + +**Eviction behavior:** +- The reaper runs on a background thread at `pool_idle_timeout` intervals +- For each tenant pool, checks `last_accessed` timestamp +- Pools idle beyond `pool_idle_timeout` are disconnected and removed from both `Apartment.pool_manager` and ActiveRecord's `ConnectionHandler` +- If `max_total_connections` is set and exceeded, LRU eviction removes the least-recently-accessed pools until under the limit +- The default tenant pool is never evicted + +**Interaction with forking servers (Puma, Unicorn):** +- The reaper timer is NOT started during `preload_app` / before fork +- Railtie registers an `on_worker_boot` callback that starts the reaper in each forked worker +- Each worker's `Concurrent::Map` starts empty (pools are re-created lazily after fork) +- ActiveRecord's own `clear_all_connections!` on fork is respected + +**Graceful shutdown:** +- `Apartment::PoolReaper.stop` is registered as an `at_exit` hook +- Cancels the timer and disconnects all tenant pools cleanly + +### Pool Sizing & Eviction + +With 600+ tenants, naive pool-per-tenant would exhaust database connections. Smart pool management keeps it bounded: + +| Config | Default | Purpose | +|--------|---------|---------| +| `tenant_pool_size` | `5` | Connections per tenant pool (matches Rails default) | +| `pool_idle_timeout` | `300` | Seconds before idle pool connections are reclaimed | +| `max_total_connections` | `nil` | Optional hard cap; LRU-evicts when exceeded | + +**Steady state for a 5-thread Puma worker:** +- Active connections at any instant: 5 (one per thread, one tenant each) +- Cached idle connections: proportional to recently-accessed tenants +- With 60-300s eviction, only "hot" tenants maintain pools + +**Example: 600 tenants, 10 Puma workers, 5 threads each:** +- If ~50 tenants are hot per worker in a given minute: ~50 pools × 1-2 active connections ≈ 50-100 connections per worker +- Across fleet: ~500-1000 total connections (well within typical `max_connections` budgets) + +### Pool Observability + +```ruby +Apartment::Tenant.pool_stats +# => { +# total_pools: 47, +# total_connections: 142, +# active_connections: 5, +# idle_connections: 137, +# evictions_total: 312, +# evictions_last_hour: 8, +# pools_by_tenant: { "acme" => { size: 5, active: 1, idle: 4, last_accessed: ... }, ... } +# } +``` + +ActiveSupport::Notifications instrumentation: +```ruby +ActiveSupport::Notifications.subscribe("apartment.pool_stats") do |event| + StatsD.gauge("apartment.total_pools", event.payload[:total_pools]) + StatsD.gauge("apartment.total_connections", event.payload[:total_connections]) +end +``` + +Thor commands: +```bash +apartment pool:stats # Summary +apartment pool:stats --verbose # Per-tenant breakdown +apartment pool:evict # Force idle pool eviction +``` + +### Switching API + +```ruby +Apartment::Tenant.switch("acme") { ... } # Block-scoped, guaranteed cleanup +Apartment::Tenant.switch!("acme") # Direct switch (discouraged) +Apartment::Tenant.current # Reads Apartment::Current.tenant +Apartment::Tenant.reset # Returns to default tenant +``` + +`switch` sets `Apartment::Current.tenant`, looks up (or lazily creates) the pool, yields, and restores `previous_tenant` in an `ensure` block. + +### Cross-Tenant Queries & Transactions + +**What works:** Cross-schema queries. PostgreSQL allows `SELECT * FROM other_schema.table` regardless of the connection's `search_path`. A tenant connection can read/write excluded model tables in the `public` schema. + +**What doesn't work:** Wrapping a tenant write and an excluded-model write in a single database transaction. Different pools = different connections = can't share a transaction. + +**Excluded models pool ownership:** Excluded models get a dedicated, shared connection pool pinned to the default tenant. This pool is separate from the default tenant's pool in the `Concurrent::Map` — it is registered once during `Apartment::Tenant.init` via `establish_connection` on each excluded model class (same pattern as v3). The excluded models pool is never evicted. This means: +- Excluded model queries always go through the default tenant pool, regardless of `Apartment::Current.tenant` +- A tenant context and an excluded model can be used in the same request, but on different connections +- Cross-schema reads work (PostgreSQL allows `SELECT * FROM public.users` from any connection) +- Cross-pool transactions do NOT work — a tenant write and an excluded model write cannot share a database transaction + +**Migration guidance for affected users (e.g., delayed_job in untenanted DB):** Writing a job record from a tenant context works because the excluded model's connection handles it independently. The trade-off is that the job insert and the tenant data change are not in the same transaction. If transactional consistency is required, users should move the job table into tenant schemas or use a two-phase approach. + +## Configuration + +```ruby +# config/initializers/apartment.rb +Apartment.configure do |config| + # Required: isolation strategy + config.tenant_strategy = :schema # :schema, :database_name, :shard, :database_config + + # Required: callable returning current tenant list + config.tenants_provider = -> { Company.pluck(:subdomain) } + + # Default tenant (PostgreSQL: "public", MySQL: derived from database.yml) + config.default_tenant = "public" + + # Models in shared/default tenant + config.excluded_models = %w[User Company] + + # Schemas always in search_path (PostgreSQL only) + # Common pattern: ["ext", "public"] for extensions in a dedicated schema + config.persistent_schemas = %w[ext public] + + # Environment-aware tenant naming + config.environmentify_strategy = :prepend # :prepend, :append, or nil + + # Tenant lifecycle + config.seed_after_create = false + config.seed_data_file = "db/seeds/tenants.rb" + + # Pool management + config.tenant_pool_size = 5 # Connections per tenant pool + config.pool_idle_timeout = 300 # Seconds before idle pool eviction + config.max_total_connections = nil # Optional hard cap + + # Parallel migrations + config.parallel_migration_threads = 0 # 0 = sequential + config.parallel_strategy = :auto # :auto (threads), :threads, :processes (opt-in) + + # Elevator (auto-inserted as middleware via middleware.use) + # Always pass a class — Rack instantiates it with (app). Use elevator_options for config. + config.elevator = Apartment::Elevators::Subdomain + # For Header elevator: + # config.elevator = Apartment::Elevators::Header + # config.elevator_options = { header: "X-Tenant-Id", trusted: true } + + # Tenant-not-found handling + config.tenant_not_found_handler = ->(tenant, request) { + [404, {}, ["Tenant not found"]] + } + + # Database-specific config blocks + config.configure_postgres do |pg| + pg.persistent_schemas = %w[ext public] + pg.enforce_search_path_reset = true + pg.include_schemas_in_dump = %w[ext shared] + end + + config.configure_mysql do |mysql| + # MySQL-specific options + end +end +``` + +**Config precedence:** Database-specific blocks (`configure_postgres`, `configure_mysql`) override top-level settings. For example, if `config.persistent_schemas = %w[public]` and `pg.persistent_schemas = %w[ext public]`, the PostgreSQL adapter uses `["ext", "public"]`. Top-level settings serve as defaults for adapters that don't have a specific block. + +**Changes from v3:** + +| v3 | v4 | Notes | +|----|-----|-------| +| `tenant_names` (array or callable) | `tenants_provider` (must be callable) | Stricter interface | +| `use_schemas = true` | `tenant_strategy = :schema` | Explicit, covers all four strategies | +| `prepend_environment` / `append_environment` | `environmentify_strategy = :prepend` | Single setting | +| `current_tenant` | `current` | API cleanup | +| `reset!` | `reset` | API cleanup | +| N/A | `tenant_pool_size`, `pool_idle_timeout` | New pool management | +| N/A | `config.elevator = ...` | Auto-insertion (was manual middleware.use) | +| N/A | `tenant_not_found_handler` | Configurable error handling | +| N/A | `pg.include_schemas_in_dump` | Addresses #303 | + +## Adapters + +### Hierarchy + +``` +Apartment::Adapters::AbstractAdapter + +-- PostgreSQLSchemaAdapter (:schema + postgresql) + +-- PostgreSQLDatabaseAdapter (:database_name + postgresql) + +-- MySQL2Adapter (:database_name + mysql2) + +-- TrilogyAdapter (:database_name + trilogy) + +-- SQLite3Adapter (:database_name + sqlite3) +``` + +JDBC adapters dropped (negligible JRuby usage, high maintenance). PostGIS adapter dropped (users should use the PostgreSQL adapters with PostGIS-enabled connections; the adapters handle isolation the same way). + +**Strategy x Database matrix:** + +| Strategy | PostgreSQL | MySQL | SQLite | +|----------|-----------|-------|--------| +| `:schema` | PostgreSQLSchemaAdapter | N/A | N/A | +| `:database_name` | PostgreSQLDatabaseAdapter | MySQL2Adapter / TrilogyAdapter | SQLite3Adapter | +| `:shard` | delegates to Rails `connected_to` | same | same | +| `:database_config` | full config override per tenant | same | same | + +Adapter selection is automatic based on `tenant_strategy` + the database adapter detected from `database.yml`. The `:schema` strategy is only valid with PostgreSQL — using it with MySQL or SQLite raises `ConfigurationError` at boot. + +**Why PostgreSQL supports both strategies:** Schema-per-tenant (`:schema`) is the primary and recommended path — fast switching, shared connection pool benefits, and `persistent_schemas` for extensions. Database-per-tenant (`:database_name`) provides stronger isolation boundaries: separate `pg_dump` per tenant, independent extensions, and full database-level access control. Use database-per-tenant when regulatory or security requirements demand complete isolation. + +The `:shard` and `:database_config` strategies reuse the same adapter classes but with different `resolve_connection_config` implementations. + +### AbstractAdapter + +Responsibilities: +- `create(tenant)` — create schema/database, run migrations, optionally seed +- `drop(tenant)` — drop schema/database, remove cached pool +- `switch(tenant, &block)` — set `Current.tenant`, resolve pool, yield, restore +- `switch!(tenant)` — direct switch without block +- `reset` — switch to default tenant +- `migrate(tenant, version)` — run migrations within tenant's pool +- `seed(tenant)` — run seeds within tenant context + +Callbacks via `ActiveSupport::Callbacks` on `:create` and `:switch` (same as v3). + +### PostgreSQLSchemaAdapter + +Primary strategy. Pool config sets `schema_search_path` at connection creation time. + +```ruby +def resolve_connection_config(tenant) + base_config.merge( + schema_search_path: [tenant, *persistent_schemas].join(",") + ) +end +``` + +Handles: +- Schema creation/dropping via `CREATE SCHEMA` / `DROP SCHEMA CASCADE` +- Extension availability via `persistent_schemas` (e.g., `["ext", "public"]` ensures `pgcrypto`, `uuid-ossp` etc. are accessible in tenant schemas — addresses #321) +- Rails 8.1 schema dump patch: strips `public.` prefix when loading structure into tenant schemas (#341) +- Excluded model table names prefixed: `public.users` + +### PostgreSQLDatabaseAdapter + +Database-per-tenant on PostgreSQL. Same pool-per-tenant model, but varies `database` instead of `schema_search_path`. + +```ruby +def resolve_connection_config(tenant) + base_config.merge(database: tenant_database_name(tenant)) +end +``` + +Handles: +- Database creation/dropping via `CREATE DATABASE` / `DROP DATABASE` +- Each tenant has fully independent schemas, extensions, and access control +- Excluded model table names reference the default database: `default_db.users` + +Trade-offs vs schema adapter: +- (+) Stronger isolation (separate `pg_dump`, independent extensions, database-level `GRANT`) +- (+) No `search_path` concerns — each database is self-contained +- (-) Slower switching (new connection per database vs search_path change) +- (-) Cannot cross-query between tenants (no `other_schema.table` access) +- (-) Higher connection count (one pool per database, not shared) + +### MySQL2Adapter / TrilogyAdapter + +Pool config sets `database` at connection creation time. + +```ruby +def resolve_connection_config(tenant) + base_config.merge(database: tenant_database_name(tenant)) +end +``` + +Excluded model table names prefixed: `default_db.users`. + +### SQLite3Adapter + +File-per-tenant isolation. Development/testing use case. + +## Elevators (Middleware) + +### Strategies + +``` +Apartment::Elevators::Generic # Base class + +-- Subdomain # tenant from subdomain (PublicSuffix) + +-- FirstSubdomain # first segment of nested subdomains + +-- Domain # domain minus TLD + +-- Host # full hostname + +-- HostHash # hostname -> tenant lookup table + +-- Header # tenant from trusted HTTP header (NEW) +``` + +### Base Pattern + +```ruby +class Generic + def initialize(app, processor = nil) + @app = app + @processor = processor || method(:parse_tenant_name) + end + + def call(env) + request = Rack::Request.new(env) + tenant = @processor.call(request) + + if tenant + Apartment::Tenant.switch(tenant) { @app.call(env) } + else + @app.call(env) + end + end +end +``` + +Exception-safe by design — `switch` block guarantees cleanup. + +### Header Elevator (New) + +For infrastructure that injects tenant identity at the edge (CloudFront, Nginx, API gateway). + +```ruby +config.elevator = Apartment::Elevators::Header +config.elevator_options = { header: "X-Tenant-Id", trusted: true } +``` + +Security model: +- `trusted: false` (default): logs a prominent warning at boot — "Header-based tenant resolution trusts the client to provide the correct tenant. Only use this when the header is injected by trusted infrastructure (CDN, reverse proxy) that strips client-supplied values." +- `trusted: true`: warning suppressed; developer has acknowledged the trust model +- Missing header: falls through to default tenant (same as other elevators returning nil) + +**Example pattern:** A CloudFront or Nginx edge function strips any client-injected `X-Tenant-Id` header, then sets it only from a trusted lookup (KVS, database, config). The app can trust the header because it can only arrive through the trusted edge layer. + +## Job Middleware + +### Core Mechanism + +`Apartment::Current.tenant` propagated via `ActiveSupport::CurrentAttributes`. + +### Sidekiq (7+) + +Sidekiq natively serializes/restores `CurrentAttributes`. Apartment ships a server middleware that reads the restored value and establishes the pool context: + +```ruby +class Apartment::Jobs::SidekiqMiddleware + def call(worker, job, queue) + tenant = Apartment::Current.tenant + if tenant + Apartment::Tenant.switch(tenant) { yield } + else + yield + end + end +end +``` + +Auto-registered via Railtie if Sidekiq is detected. No Sidekiq version constraint declared — works on 7+ and 8+. + +### SolidQueue + +Natively propagates `CurrentAttributes`. Same pattern — hook reads `Apartment::Current.tenant` and establishes pool context. + +### ActiveJob (Generic Fallback) + +For job backends that don't support `CurrentAttributes` natively, an `around_perform` callback serializes tenant into job metadata: + +```ruby +# In ApplicationJob: +include Apartment::Jobs::ActiveJobExtension +``` + +### Custom Integrations + +Public API: `Apartment::Current.tenant` (read), `Apartment::Tenant.switch(tenant) { ... }` (establish context). + +## Migrations & Rake Tasks + +### Simplified Parallel Migrations + +v3's 293-line parallel migration orchestration (fork-vs-thread detection, advisory lock management, connection pool clearing) is simplified because pool-per-tenant eliminates connection contention. + +**What's gone:** +- Advisory lock management (each pool has its own lock space) +- Connection clearing/re-establishment after fork +- Platform detection defaults to threads + +**What remains:** +- `parallel_strategy: :auto` resolves to `:threads` (default) +- `parallel_strategy: :processes` available as opt-in for Linux deployments with CPU-heavy migrations +- `Result` struct with per-tenant success/failure tracking +- `display_summary` showing succeeded/failed counts +- Schema dump after migration from canonical default tenant + +### Multi-Database Rake Task Enhancement + +Carried from v3.4.1. Detects Rails multi-database configs and auto-enhances namespaced tasks: +- `db:migrate:primary` -> `apartment:migrate` +- `db:rollback:primary` -> `apartment:rollback` +- `db:seed:primary` -> `apartment:seed` + +Only enhances databases with `database_tasks: true` and `replica: false`. + +### Thor CLI (Primary Interface) + +```bash +apartment create # Create all tenants +apartment create acme # Create specific tenant +apartment drop acme # Drop specific tenant +apartment migrate # Migrate all tenants +apartment migrate acme # Migrate specific tenant +apartment rollback --steps=2 # Rollback all tenants +apartment seed # Seed all tenants +apartment list # List all tenants +apartment current # Show current tenant +apartment pool:stats # Pool usage summary +apartment pool:stats --verbose # Per-tenant breakdown +apartment pool:evict # Force idle pool eviction +``` + +The `apartment` command is made available via a binstub generated by `rails generate apartment:install` (creates `bin/apartment`) or by adding `ros-apartment` to the `Gemfile` (Thor auto-discovers the CLI via the gem's `lib/apartment/cli.rb`). + +### Rake Tasks (Thin Wrappers) + +Rake tasks delegate to Thor commands for backward compatibility: + +```ruby +namespace :apartment do + task migrate: :environment do + Apartment::CLI.new.migrate + end +end +``` + +## Rails Integration (Railtie) + +```ruby +class Apartment::Railtie < Rails::Railtie + initializer "apartment.configure" do + Apartment.validate_config! # Fail fast: tenant_strategy required, tenants_provider must be callable + + # Warn if isolation_level is :thread — CurrentAttributes won't propagate to load_async + if Rails.application.config.active_support.isolation_level == :thread + Rails.logger.warn "[Apartment] active_support.isolation_level is :thread. " \ + "Apartment requires :fiber (Rails 7.2+ default) for correct tenant propagation " \ + "to load_async and ActionController::Live threads." + end + end + + initializer "apartment.process_excluded_models", after: :load_config_initializers do + config.to_prepare do + Apartment::Tenant.init # Process excluded models, establish default pools + end + end + + initializer "apartment.middleware" do |app| + if Apartment.config.elevator + opts = Apartment.config.elevator_options || {} + app.middleware.use Apartment.config.elevator, **opts + end + end + + initializer "apartment.job_middleware" do + if defined?(Sidekiq) + Sidekiq.configure_server do |config| + config.server_middleware do |chain| + chain.add Apartment::Jobs::SidekiqMiddleware + end + end + end + end + + initializer "apartment.current_attributes" do + ActiveSupport.on_load(:active_record) do + Apartment::Current # Ensure loaded for propagation + end + end + + rake_tasks do + load "apartment/tasks.rb" + end +end +``` + +### ActiveRecord Patches + +Minimal surface via `prepend` (not v3's `alias_method` chains): + +```ruby +ActiveSupport.on_load(:active_record) do + ActiveRecord::Base.prepend Apartment::Patches::ConnectionHandling +end +``` + +Patched methods: +- `connection_pool` — returns tenant-specific pool based on `Apartment::Current.tenant` +- `retrieve_connection` — tenant-aware connection checkout +- `establish_connection` — tenant-aware pool creation + +Everything else delegates to Rails' native behavior. + +### Rails 8.1 Schema Dumper Patch + +Addresses #341. When loading `schema.rb` into a tenant schema, strips `public.` prefix from table names so tables are created in the tenant's schema rather than `public`. + +Configurable `include_schemas_in_dump` for users who need non-tenant schemas (shared, analytics, extensions) in their schema dump (#303). + +## Error Handling + +### Exception Hierarchy + +``` +Apartment::ApartmentError # Base class + +-- Apartment::TenantNotFound # Tenant doesn't exist + +-- Apartment::TenantExists # Tenant already exists (on create) + +-- Apartment::AdapterNotFound # Invalid tenant_strategy + +-- Apartment::ConfigurationError # Missing/invalid config (tenant_strategy, tenants_provider) + +-- Apartment::PoolExhausted # max_total_connections exceeded, eviction couldn't free pools + +-- Apartment::SchemaLoadError # Failed to load schema into tenant (migration/structure issue) +``` + +### tenants_provider Error Handling + +`tenants_provider` is called at runtime (tenant creation, migration, listing). Failure modes: + +- **Database unavailable / table doesn't exist**: Rescued with `ActiveRecord::StatementInvalid` and `ActiveRecord::NoDatabaseError`. Returns empty array. Logs warning. This matches v3's behavior and allows the app to boot even if the tenants table hasn't been migrated yet. +- **Returns non-array**: `ConfigurationError` raised at call time. +- **Stale data**: Not cached by default. Each call hits the callable fresh. Users who want caching should implement it in their callable (e.g., `Rails.cache.fetch`). + +### Pool Error Handling + +- **Connection checkout timeout** within a tenant pool: ActiveRecord's `ConnectionTimeoutError` propagates unmodified. Users tune via `tenant_pool_size` or `checkout_timeout`. +- **Pool creation failure** (e.g., database connection refused): `ActiveRecord::ConnectionNotEstablished` propagates. The pool is NOT cached in the `Concurrent::Map` on failure, so the next access retries. +- **`max_total_connections` exceeded**: LRU eviction runs. If no pools can be evicted (all active), raises `Apartment::PoolExhausted`. + +### Elevator Error Handling + +- **Tenant not found** (elevator resolves a tenant name that doesn't exist): Calls `config.tenant_not_found_handler` if configured. Default behavior: raises `Apartment::TenantNotFound`. +- **Elevator raises**: Exception propagates to Rack error handling. No tenant context is set. + +## Generator + +`rails generate apartment:install` creates `config/initializers/apartment.rb` with annotated defaults. Carried forward from v3 with updated config keys. + +```bash +rails generate apartment:install +# Creates config/initializers/apartment.rb with v4 config template +``` + +## Backward Compatibility with apartment-sidekiq + +The upgrade guide (step 6) notes removing the `apartment-sidekiq` gem. Additional handling: + +- **Detection**: If `Apartment::Sidekiq` (from the old gem) is defined at boot, v4 logs a warning: "apartment-sidekiq is not needed with Apartment 4.x. Built-in Sidekiq middleware handles tenant propagation via CurrentAttributes. Remove apartment-sidekiq from your Gemfile." +- **Job format compatibility**: Jobs enqueued with v3 + `apartment-sidekiq` store the tenant in `job["apartment"]`. v4's Sidekiq middleware checks for this key as a fallback if `Apartment::Current.tenant` is nil, enabling zero-downtime upgrades where old-format jobs are still in the queue during the transition. +- **No conflict**: If both are loaded, v4's middleware takes precedence (registered later in the chain). The old gem's middleware becomes a no-op since the tenant is already set. + +## Notification Events + +Following ActiveSupport::Notifications `"verb.namespace"` convention: + +| Event | Payload | When | +|-------|---------|------| +| `switch.apartment` | `{ tenant:, previous_tenant: }` | Tenant switch (block or bang) | +| `create.apartment` | `{ tenant: }` | Tenant created | +| `drop.apartment` | `{ tenant: }` | Tenant dropped | +| `evict.apartment` | `{ tenant:, reason: }` | Pool evicted (idle/lru) | +| `pool_stats.apartment` | `{ total_pools:, total_connections:, ... }` | Periodic stats (if subscribed) | + +## Testing Strategy + +### Test Structure + +``` +spec/ + unit/ + config_spec.rb + current_spec.rb + tenant_spec.rb + adapters/ + abstract_adapter_spec.rb + postgresql_adapter_spec.rb + mysql2_adapter_spec.rb + trilogy_adapter_spec.rb + elevators/ + subdomain_spec.rb + first_subdomain_spec.rb + domain_spec.rb + host_spec.rb + host_hash_spec.rb + header_spec.rb + jobs/ + sidekiq_middleware_spec.rb + solid_queue_spec.rb + active_job_extension_spec.rb + migrator_spec.rb + tasks/ + task_helper_spec.rb + schema_dumper_spec.rb + rake_task_enhancer_spec.rb + cli_spec.rb + pool_manager_spec.rb + integration/ + connection_pool_isolation_spec.rb + thread_safety_spec.rb + fiber_safety_spec.rb + request_lifecycle_spec.rb + migration_spec.rb + excluded_models_spec.rb + stress/ + rapid_switching_spec.rb + concurrent_access_spec.rb + memory_stability_spec.rb + pool_eviction_spec.rb + dummy/ + # Minimal Rails app for integration tests +``` + +### CI Matrix + +**Appraisals:** +- Rails 7.2 + PostgreSQL +- Rails 7.2 + MySQL +- Rails 8.0 + PostgreSQL +- Rails 8.0 + MySQL +- Rails 8.0 + SQLite3 +- Rails 8.1 + PostgreSQL +- Rails 8.1 + MySQL +- Rails 8.1 + SQLite3 + +**Ruby:** 3.3 and 3.4 + +**Database selection:** `DB=postgresql rspec` (or `mysql`, `sqlite3`) — same env var as v3 for contributor continuity. + +## Upgrade Path + +### v3.5.0 (Deprecation Bridge) + +Final v3.x release with deprecation warnings pointing to v4 equivalents: + +```ruby +config.tenant_names = [...] +# => DEPRECATION: tenant_names is removed in Apartment 4.0. +# Use tenants_provider with a callable instead. + +config.use_schemas = true +# => DEPRECATION: use_schemas is removed in Apartment 4.0. +# Use tenant_strategy = :schema instead. + +Apartment::Tenant.current_tenant +# => DEPRECATION: current_tenant is removed in Apartment 4.0. Use .current instead. + +Apartment::Tenant.reset! +# => DEPRECATION: reset! is removed in Apartment 4.0. Use .reset instead. +``` + +### v4.0.0 Upgrade Guide + +Checklist format in `docs/upgrading-to-v4.md`: + +1. **Prerequisites**: Ruby 3.3+, Rails 7.2+ +2. **Configuration migration**: v3 -> v4 config key mapping table +3. **API changes**: `current_tenant` -> `current`, `reset!` -> `reset`, `tenant_names` -> `tenants_provider` +4. **Initializer rewrite**: example v3 initializer -> equivalent v4 initializer +5. **Elevator changes**: same classes, optional `config.elevator` auto-insertion +6. **Job middleware**: remove `apartment-sidekiq` gem, built-in now +7. **Rake -> Thor**: `rake apartment:migrate` still works, `apartment migrate` preferred +8. **Excluded models**: no change required +9. **Test updates**: helpers referencing v3 internals + +No compatibility shims in v4 — clean break. + +**Gem naming:** stays `ros-apartment`. Version jump 3.5 -> 4.0 signals the break. + +## Open Issues Resolution + +| Issue | Status in v4 | +|-------|-------------| +| #302 PgBouncer/RDS Proxy session pinning | Improved: per-switch `SET` eliminated; connection-level config with libpq `options` avoids session pinning. See PgBouncer section for details. | +| #239 Concurrency in specs | Solved: `CurrentAttributes` provides thread/fiber isolation | +| #199 `load_async` ignores tenant | Solved: `CurrentAttributes` propagates to async threads | +| #304 ActionController::Live | Solved: `CurrentAttributes` propagates to spawned threads | +| #323 Connection leaks under load | Solved: pools cached in `Concurrent::Map`, lazy creation, eviction | +| #341 Rails 8.1 `public.` prefix | Solved: schema dumper patch strips prefix for tenant loading | +| #303 Missing `create_schema` in schema.rb | Solved: `include_schemas_in_dump` config option | +| #321 `gen_random_uuid()` not found | Solved: `persistent_schemas` includes extension schema by default | +| #339 Ruby 3.3 SyntaxError | Non-issue: fresh codebase, no anonymous block forwarding in nested contexts | +| #314 ActiveStorage multitenancy | Out of scope: document patterns, potential companion gem | + +## Out of Scope + +- **ActiveStorage integration**: Document patterns, potential `apartment-activestorage` companion gem +- **Rails `connects_to` wrapper for excluded models**: May add in v4.1+ +- **Rails 8.2+ support**: Added as Rails releases +- **JDBC adapters**: Dropped; can return in 4.x if demand exists +- **Automatic shard swapping middleware**: Rails doesn't support this natively; users handle via custom middleware or `around_action` diff --git a/docs/designs/phase-2.3-connection-handling.md b/docs/designs/phase-2.3-connection-handling.md new file mode 100644 index 00000000..f8dd5c32 --- /dev/null +++ b/docs/designs/phase-2.3-connection-handling.md @@ -0,0 +1,365 @@ +# Phase 2.3 Design: Connection Handling & Pool Wiring + +> **Parent spec**: [`apartment-v4.md`](apartment-v4.md) +> **Phase plan**: [`plans/apartment-v4/phase-2-adapters.md`](../plans/apartment-v4/phase-2-adapters.md) — Tasks 2, 9 +> **Research**: [`research/connection-handling-internals.md`](../research/connection-handling-internals.md) +> **Depends on**: Phase 2.2 (concrete adapters), Phase 1 (Config, Current, PoolManager, PoolReaper) + +## Overview + +Phase 2.3 implements the connection between `Apartment::Current.tenant` and ActiveRecord's connection pool resolution. When a tenant is set, AR queries must transparently use a connection pool configured for that tenant — without any `SET search_path` or `USE database` commands at switch time. + +This is the architecturally sensitive piece: a single module prepended on `ActiveRecord::Base` that intercepts `connection_pool` lookups. + +## Goals + +1. `Apartment::Tenant.switch("acme") { User.count }` resolves a tenant-specific connection pool +2. Pool-per-tenant with immutable config — connections cannot leak tenant data +3. Works across Rails 7.2, 8.0, and 8.1 without version gates +4. Lazy pool creation on first access, cached for subsequent lookups +5. Clean eviction: pools deregistered from both `Apartment::PoolManager` and AR's `ConnectionHandler` +6. Configurable shard key prefix to avoid collisions with user-defined shards + +## Non-Goals + +- Replacing v3 elevators or middleware (Phase 3) +- Excluded model handling (Phase 2.4) +- Integration tests with real databases (Phase 2.4+) +- `connected_to` / `connected_to_many` interop (future — document limitations) + +## Design + +### 1. `Apartment::Patches::ConnectionHandling` + +A module prepended on `ActiveRecord::Base` (class-level). Overrides one method: `connection_pool`. + +**Pool key format**: We use `tenant.to_s` as the `PoolManager` key (e.g., `"acme"`). The parent spec's pseudocode uses `"#{connection_specification_name}[#{tenant}]"` — we deliberately simplify this because `PoolManager` is apartment-internal (not shared with AR's pool namespace), so the prefix adds no value. `AbstractAdapter#drop` already uses `tenant.to_s` as the pool key. + +```ruby +module Apartment + module Patches + module ConnectionHandling + def connection_pool + tenant = Apartment::Current.tenant + default = Apartment.config&.default_tenant + + return super if tenant.nil? || tenant == default + return super unless Apartment.pool_manager + + pool_key = tenant.to_s + + Apartment.pool_manager.fetch_or_create(pool_key) do + config = Apartment.adapter.resolve_connection_config(tenant) + shard_key = :"#{Apartment.config.shard_key_prefix}_#{tenant}" + + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + Apartment.config.rails_env_name, + "apartment_#{tenant}", + config + ) + + # owner_name receives the class (not a string) so AR wraps it + # consistently with how it stores the default pool. This matters + # because remove_connection_pool uses the string form of the + # connection_name ("ActiveRecord::Base") which AR derives from + # the class's name. Both establish_connection and + # remove_connection_pool resolve to the same pool manager key + # across Rails 7.2/8.0/8.1 — verified in research doc. + ActiveRecord::Base.connection_handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: ActiveRecord::Base.current_role, + shard: shard_key + ) + end + end + end + end +end +``` + +**Activation**: `ActiveRecord::Base.singleton_class.prepend(Apartment::Patches::ConnectionHandling)` — called during Railtie initialization or explicit `Apartment.activate!`. + +**Why `connection_pool` and not `connection` / `lease_connection`**: `connection_pool` is the single chokepoint. Both `connection` and `lease_connection` delegate to it. Overriding here means all AR access patterns (`.connection`, `.with_connection`, `.lease_connection`, query execution) route through our patch. + +**Why `prepend` and not `alias_method`**: `prepend` is the modern Ruby pattern. `super` calls the original method cleanly. No naming collisions, no `alias_method_chain` fragility. + +**Adapter lazy-loading safety**: The `Apartment.adapter` call inside `fetch_or_create` triggers `build_adapter` if the adapter hasn't been built yet. `build_adapter` calls `ActiveRecord::Base.connection_db_config` (reads config metadata, does not establish a connection), so it does not recurse through `connection_pool`. This is safe. + +### 2. Pool Resolution Flow + +``` +User code: User.first + → ActiveRecord::Base.connection_pool # our override + → Apartment::Current.tenant == "acme"? + → yes → pool_manager.fetch_or_create("acme") + → cache hit? + → yes → return cached pool (sub-millisecond) + → no → adapter.resolve_connection_config("acme") + → HashConfig.new(env, "apartment_acme", config) + → handler.establish_connection(db_config, shard: :apartment_acme) + → AR creates pool lazily (no DB connection yet) + → pool stored in PoolManager + AR's ConnectionHandler + → return pool + → pool.lease_connection → execute query +``` + +### 3. Data Isolation Guarantee + +Each tenant pool has **immutable, tenant-specific config** baked in at creation: + +| Strategy | Config key | Example | +|----------|-----------|---------| +| PostgreSQL schema | `schema_search_path` | `"acme,ext,public"` | +| PostgreSQL database | `database` | `"acme_production"` | +| MySQL database | `database` | `"acme_production"` | +| SQLite file | `database` | `"storage/acme.sqlite3"` | + +A connection checked out from tenant A's pool is **physically unable** to access tenant B's data. No runtime SQL commands change the tenant context — the pool *is* the tenant boundary. + +For PostgreSQL schema strategy: Rails' `PostgreSQLAdapter#configure_connection` issues a one-time `SET search_path` when establishing each new connection within the pool. This happens once per connection (not per request), and the search_path matches the pool's config. See the parent spec for PgBouncer compatibility notes. + +### 4. Shard Key Namespacing + +Tenant shard keys are prefixed to avoid collisions with user-defined shards: + +```ruby +shard_key = :"#{config.shard_key_prefix}_#{tenant}" +# With default prefix: :apartment_acme +# With custom prefix: :myapp_acme +``` + +**Config addition**: `config.shard_key_prefix` — string, default `"apartment"`, validated as `/\A[a-z_][a-z0-9_]*\z/`. + +User apps that use `connects_to shards: { shard_one: ... }` will not collide with `:apartment_acme`. + +### 5. Config Changes + +#### New attributes on `Apartment::Config` + +| Attribute | Type | Default | Purpose | +|-----------|------|---------|---------| +| `shard_key_prefix` | String | `"apartment"` | Prefix for shard keys in AR's ConnectionHandler | + +#### New method on `Apartment::Config` + +`rails_env_name` — returns `Rails.env` when available, falls back to `ENV["RAILS_ENV"]`, `ENV["RACK_ENV"]`, or `"default_env"`. Mirrors ActiveRecord's own `ConnectionHandling::DEFAULT_ENV` lambda. Used as the `env_name` parameter for `HashConfig.new`. + +#### Validation + +`shard_key_prefix` validated in `validate!`: +- Must be a non-empty string +- Must match `/\A[a-z_][a-z0-9_]*\z/` (safe for `to_sym`) +- Raises `ConfigurationError` on invalid values + +### 6. Pool Eviction — AR Handler Cleanup + +When `PoolReaper` evicts a tenant, it must clean up both sides. Both `evict_idle` and `evict_lru` methods must be updated to include AR handler cleanup (the current code only removes from `PoolManager`). + +Updated eviction flow per tenant: + +```ruby +# 1. Remove from our tracking (prevents new lookups) +pool = @pool_manager.remove(tenant_key) + +# 2. Deregister from AR's handler (disconnects connections) +# remove_connection_pool accepts the connection_name as a string. +# AR's ConnectionHandler resolves "ActiveRecord::Base" → same pool +# manager used by establish_connection(owner_name: ActiveRecord::Base). +shard_key = :"#{@shard_key_prefix}_#{tenant_key}" +begin + ActiveRecord::Base.connection_handler.remove_connection_pool( + "ActiveRecord::Base", + role: ActiveRecord::Base.current_role, + shard: shard_key + ) +rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to deregister pool for #{tenant_key}: #{e.class}: #{e.message}" +end +``` + +**Order matters**: Remove from `PoolManager` first so concurrent `connection_pool` calls don't find a stale entry. Then deregister from AR's handler, which calls `disconnect!` on the pool. + +**`PoolManager#clear` also needs AR cleanup**: When `teardown_old_state` calls `@pool_manager.clear`, it must also deregister each pool from AR's handler. Otherwise stale shard registrations persist in AR's `ConnectionHandler` after reconfigure. The updated `clear` method iterates tracked tenant keys and calls `remove_connection_pool` for each before clearing the maps. + +The `on_evict` callback (existing) fires after removal for instrumentation/logging. + +### 7. PoolReaper: Class Singleton → Instance + +**Current state**: `PoolReaper` uses class-level `@mutex`, `@timer`, and class methods. Works but prevents test isolation and couples global state. + +**New design**: `PoolReaper` becomes an instance held by `Apartment`: + +```ruby +module Apartment + class << self + attr_reader :pool_reaper + + def configure + # ... validate, freeze ... + teardown_old_state + @pool_manager = PoolManager.new + @pool_reaper = PoolReaper.new( + pool_manager: @pool_manager, + interval: new_config.pool_idle_timeout, + idle_timeout: new_config.pool_idle_timeout, + max_total: new_config.max_total_connections, + default_tenant: new_config.default_tenant, + shard_key_prefix: new_config.shard_key_prefix + ) + @config = new_config + @pool_reaper.start + end + + def clear_config + teardown_old_state + @config = nil + @pool_manager = nil + @pool_reaper = nil + end + end +end +``` + +**Reap interval**: Uses `pool_idle_timeout` as the timer interval (matching the parent spec and existing behavior). No separate `pool_reap_interval` attribute — the reaper checks at the same frequency as the idle timeout. This means a pool could live up to 2x the idle timeout before eviction (idle for `timeout` seconds, then up to `timeout` more seconds until the next check). This is acceptable for the intended use case. A separate reap interval can be added later if needed. + +**`clear_config` update**: Must call `teardown_old_state` (which stops the reaper instance) instead of `PoolReaper.stop` (the old class-method call). + +Instance API: +- `initialize(pool_manager:, interval:, idle_timeout:, max_total:, default_tenant:, shard_key_prefix:)` — validates params +- `start` — creates and executes `Concurrent::TimerTask` +- `stop` — shuts down timer, waits for termination +- `running?` — timer state check +- `reap` (private) — calls `evict_idle`, `evict_lru` + +The reaper instance holds `shard_key_prefix` so eviction can compute shard keys for `remove_connection_pool`. + +### 8. `configure` Teardown Protection + +Wrap teardown in begin/rescue so a `PoolReaper.stop` failure doesn't leave half-torn-down state: + +```ruby +def teardown_old_state + begin + @pool_reaper&.stop + rescue StandardError => e + warn "[Apartment] PoolReaper.stop failed during reconfigure: #{e.class}: #{e.message}" + end + @pool_manager&.clear + @adapter = nil +end +``` + +### 9. Activation API + +The patch must be activated explicitly (not on `require`). Two paths: + +1. **With Rails (Railtie)**: Activated in `after_initialize` — AR is guaranteed loaded. +2. **Without Rails**: `Apartment.activate!` — user calls after `Apartment.configure`. + +```ruby +module Apartment + def self.activate! + ActiveRecord::Base.singleton_class.prepend(Patches::ConnectionHandling) + end +end +``` + +Idempotent — `prepend` on an already-prepended module is a no-op. + +## File Map + +### New files + +| File | Responsibility | +|------|---------------| +| `lib/apartment/patches/connection_handling.rb` | `connection_pool` override | +| `spec/unit/patches/connection_handling_spec.rb` | Unit tests for pool resolution | + +### Modified files + +| File | Changes | +|------|---------| +| `lib/apartment/config.rb` | Add `shard_key_prefix`, `rails_env_name`, validation | +| `lib/apartment/pool_reaper.rb` | Convert from class singleton to instance, add AR handler cleanup | +| `lib/apartment.rb` | Add `pool_reaper` accessor, `activate!` method, extract `teardown_old_state`, update both `configure` and `clear_config` to use instance reaper (not class singleton) | +| `spec/unit/pool_reaper_spec.rb` | Update for instance API | +| `spec/unit/config_spec.rb` | Add `shard_key_prefix` validation tests | +| `spec/unit/apartment_spec.rb` | Add `activate!` tests, teardown protection tests | + +### Zeitwerk + +`lib/apartment/patches/` is currently in the Zeitwerk ignore list. The `connection_handling.rb` file will be loaded via `require_relative` (explicit, not autoloaded) since it must be activated at a specific point in the boot sequence. + +## Testing Strategy + +Unit tests use SQLite3 in-memory databases (no external services). Test with real AR loaded (not stubs) — the patch must exercise actual `ConnectionHandler` and `PoolConfig` behavior. + +### `spec/unit/patches/connection_handling_spec.rb` + +- Default tenant → returns `super` (normal AR pool) +- `nil` tenant → returns `super` +- Active tenant → returns tenant-specific pool (different from default) +- Same tenant twice → returns same cached pool +- Different tenants → return different pools +- Pool is registered with AR's `ConnectionHandler` +- Pool has correct `db_config` (tenant-specific settings) +- Pool is usable (can execute a simple query) +- No `PoolManager` (unconfigured) → returns `super` +- Tenant name with hyphens (e.g., `"my-tenant"`) works correctly as shard key +- Role interaction: tenant pool under `:reading` role differs from `:writing` + +### `spec/unit/pool_reaper_spec.rb` (updated) + +- Instance creation with valid params +- Instance `start`/`stop` lifecycle +- Idle eviction calls `disconnect!` and deregisters from AR handler +- LRU eviction calls `disconnect!` and deregisters from AR handler +- Default tenant is never evicted +- Multiple start/stop cycles work cleanly + +### `spec/unit/config_spec.rb` (additions) + +- `shard_key_prefix` defaults to `"apartment"` +- Valid prefix passes validation +- Invalid prefix (empty, special chars, starts with number) raises `ConfigurationError` +- `rails_env_name` returns correct value with/without Rails + +### `spec/unit/apartment_spec.rb` (additions) + +- `configure` teardown rescues `PoolReaper.stop` failure +- `activate!` prepends `ConnectionHandling` on `ActiveRecord::Base` +- `clear_config` stops reaper instance + +## Interactions and Edge Cases + +### User calls `connected_to(shard: :foo)` + +Our patch reads `Current.tenant`, not `current_shard`. If the user switches shards via `connected_to(shard:)`, our override still reads `Current.tenant` first. If tenant is set, we return our pool (ignoring the user's shard switch). If tenant is nil/default, `super` runs and respects the user's shard. + +**Implication**: Inside an `Apartment::Tenant.switch` block, user-level `connected_to(shard:)` is overridden by our tenant pool. This is the correct behavior — tenant isolation takes precedence. + +### User calls `connected_to(role: :reading)` + +If the user is inside `connected_to(role: :reading)` and also inside `Tenant.switch("acme")`, our override passes `role: ActiveRecord::Base.current_role` (which will be `:reading`) to `establish_connection`. This means tenant pools are created per `(tenant, role)` pair, not just per tenant. This is correct and intentional — a tenant's reading replica should be a different pool from its writing primary. + +### `prohibit_shard_swapping` + +Does not affect us. We don't push to `connected_to_stack`; we read `Current.tenant` directly. + +### Forking servers (Puma, Unicorn) + +After fork, `Concurrent::Map` in `PoolManager` starts empty (copy-on-write semantics). AR's `clear_all_connections!` on fork is respected. The reaper's `Concurrent::TimerTask` does NOT survive fork — must be restarted in the worker process (Railtie `on_worker_boot` hook, Phase 3). + +### `ActiveRecord::Base` subclasses with custom `connection_specification_name` + +Our patch is on `ActiveRecord::Base` singleton class. Subclasses that set their own `connection_specification_name` (e.g., via `establish_connection` or `connects_to`) resolve their pool via the parent's `connection_pool` method — which calls our override. If the subclass has its own pool (from `connects_to`), AR's `retrieve_connection_pool` finds it before reaching our code (because `super` is called only for default/nil tenant). Excluded models will be handled in Phase 2.4. + +## Risks + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| AR internal API changes in Rails 8.2+ | Medium | High | We use only public APIs (`establish_connection`, `remove_connection_pool`, `HashConfig.new`). CI matrix catches regressions. | +| `establish_connection` idempotent check fails for schema strategy (same DB, different search_path) | Low | Medium | Each tenant has a different `HashConfig` (different `schema_search_path`), so `db_config ==` comparison should differentiate. Verify in tests. | +| Thread contention on `PoolManager.fetch_or_create` | Low | Low | `Concurrent::Map.compute_if_absent` is lock-free for the common (cache hit) path. | +| Memory growth with many tenants | Medium | Medium | PoolReaper evicts idle/LRU pools. `max_total_connections` provides hard cap. | diff --git a/docs/designs/v4-elevators.md b/docs/designs/v4-elevators.md new file mode 100644 index 00000000..50ec29ba --- /dev/null +++ b/docs/designs/v4-elevators.md @@ -0,0 +1,288 @@ +# v4 Elevators Design Spec + +## Overview + +Phase 3 of the v4 rewrite: Rack middleware for automatic tenant detection from HTTP requests. Preserves the v3 elevator hierarchy and adds a new Header elevator for infrastructure-injected tenant identity. + +**Depends on:** Phase 2 (Adapters & Tenant API), Railtie (merged in #355) + +## Design Decisions + +### Constructor-only configuration + +v3 elevators used mutable class-level state (`Subdomain.excluded_subdomains=`, `Host.ignored_first_subdomains=`). v4 eliminates this in favor of keyword arguments passed through the constructor. + +Config flows: `Apartment.configure` → `config.elevator_options` → Railtie → `middleware.use(ElevatorClass, **opts)` → constructor. + +**Why:** Immutable after boot. Single configuration path. No ordering dependency between class-level setter calls and middleware insertion. Aligns with v4's frozen-config philosophy. + +**Trade-off:** Users who set class-level attributes in initializers must move that config into `elevator_options`. This is a clean break (v4 is not incremental), documented in the upgrade guide. + +### HostHash raises on missing host + +HostHash raises `TenantNotFound` when the host isn't in the mapping. All other elevators return `nil` on no-match (falls through to default tenant). + +**Why:** An explicit host→tenant mapping where the host is missing is a config error, not an expected condition. Open-ended strategies (subdomain, domain, host) can't know all valid tenants at middleware level, so nil-return is correct for them. + +### Header elevator trust warning at boot + +The Header elevator's `trusted: false` warning fires during the Railtie initializer (middleware insertion), not at first request. + +**Why:** Boot-time warnings are visible in deploy logs, actionable before traffic arrives, and don't require a request to surface. + +### Railtie passes keyword args + +The Railtie passes `elevator_options` as `**opts` (keyword args), not `*opts.values` (positional). Elevator constructors accept keyword args with defaults. + +**Why:** Positional args depend on hash insertion order — fragile. Keywords are explicit and self-documenting. + +## Architecture + +### Hierarchy + +``` +Apartment::Elevators::Generic # Base Rack middleware + ├── Subdomain # PublicSuffix-based subdomain extraction + │ └── FirstSubdomain # First segment of nested subdomains + ├── Domain # Domain minus TLD, strips www + ├── Host # Full hostname + ├── HostHash # Hostname → tenant hash lookup + └── Header # HTTP header (new) +``` + +All elevators live in `lib/apartment/elevators/`. Subclasses override `parse_tenant_name(request)` to extract the tenant identifier. + +### Generic (base class) + +```ruby +class Generic + def initialize(app, processor = nil, **_options) + @app = app + @processor = processor || method(:parse_tenant_name) + end + + def call(env) + request = Rack::Request.new(env) + tenant = @processor.call(request) + + if tenant + Apartment::Tenant.switch(tenant) { @app.call(env) } + else + @app.call(env) + end + end + + def parse_tenant_name(_request) + raise NotImplementedError, "#{self.class}#parse_tenant_name must be implemented" + end +end +``` + +Block-scoped `switch` guarantees cleanup on exceptions. The `**_options` splat absorbs keyword args from `elevator_options` so Generic works when used directly with a Proc processor. + +### Subdomain + +```ruby +class Subdomain < Generic + def initialize(app, excluded_subdomains: [], **_options) + super(app) + @excluded_subdomains = Array(excluded_subdomains).map(&:to_s).freeze + end +end +``` + +Uses PublicSuffix for international TLD handling. Returns `nil` for excluded subdomains (falls through to default tenant). Instance variable replaces class-level `excluded_subdomains=`. + +### FirstSubdomain + +Inherits Subdomain. Takes the first segment when subdomains are nested (`tenant.staging.example.com` -> `tenant`). No additional constructor args. + +**v4 fix:** v3 calls `super` twice in `parse_tenant_name` (once for nil check, once for value). v4 caches the result in a local variable. + +### Domain + +Extracts the first non-`www` segment of the hostname via regex (`/(?:www\.)?(?[^.]*)/`). For `a.example.bc.ca` this returns `a`, not `example`. No constructor args beyond Generic. + +### Host + +```ruby +class Host < Generic + def initialize(app, ignored_first_subdomains: [], **_options) + super(app) + @ignored_first_subdomains = Array(ignored_first_subdomains).map(&:to_s).freeze + end +end +``` + +Uses full hostname as tenant. Strips first subdomain if it appears in the ignored list (e.g., `www`). + +### HostHash + +```ruby +class HostHash < Generic + def initialize(app, hash: {}, **_options) + super(app) + @hash = hash.freeze + end +end +``` + +Raises `TenantNotFound` when host is not in the hash (explicit mapping; missing = config error). v3's optional `processor` positional arg is dropped; HostHash's tenant resolution is always via the hash lookup. + +### Header (new) + +```ruby +class Header < Generic + def initialize(app, header: 'X-Tenant-Id', trusted: false, **_options) + super(app) + @header_name = "HTTP_#{header.upcase.tr('-', '_')}" + @raw_header = header + end + + def parse_tenant_name(request) + request.get_header(@header_name) + end +end +``` + +For infrastructure that injects tenant identity at the edge (CloudFront, Nginx, API gateway). The `trusted:` flag is consumed by the Railtie for a boot-time warning (see below); the elevator constructor accepts it via `**_options` splat but does not store it. The elevator behaves identically regardless of trust level; trust is an operational acknowledgment, not a runtime behavior toggle. + +Missing header returns `nil` (falls through to default tenant), consistent with other elevators. + +## Railtie Changes + +### Middleware insertion (keyword args + Header warning) + +```ruby +initializer 'apartment.middleware' do |app| + next unless Apartment.config&.elevator + + elevator_class = Apartment::Railtie.resolve_elevator_class(Apartment.config.elevator) + opts = Apartment.config.elevator_options || {} + + if elevator_class <= Apartment::Elevators::Header && !opts[:trusted] + warn <<~WARNING + [Apartment] WARNING: Header elevator with trusted: false. + Header-based tenant resolution trusts the client to provide the correct tenant. + Only use this when the header is injected by trusted infrastructure (CDN, reverse proxy) + that strips client-supplied values. + WARNING + end + + app.middleware.use(elevator_class, **opts) +end +``` + +The `<=` check handles subclasses of Header. + +### `resolve_elevator_class` update (symbol or class) + +`config.elevator` accepts both symbols (`:subdomain`) and classes (`DynamicElevator`). The resolver must handle both: + +```ruby +def self.resolve_elevator_class(elevator) + return elevator if elevator.is_a?(Class) + + class_name = "Apartment::Elevators::#{elevator.to_s.camelize}" + require("apartment/elevators/#{elevator}") + class_name.constantize +rescue NameError, LoadError => e + available = Dir[File.join(__dir__, 'elevators', '*.rb')] + .filter_map { |f| name = File.basename(f, '.rb'); name unless name == 'generic' } + raise(Apartment::ConfigurationError, + "Unknown elevator '#{elevator}': #{e.message}. " \ + "Available elevators: #{available.join(', ')}") +end +``` + +Symbols are the canonical form for built-in elevators. Classes are for custom elevators that live outside the gem (e.g., `DynamicElevator`). The parent design spec's "always pass a class" convention is updated: symbols are resolved by the Railtie, classes pass through. + +## Error Handling + +Generic's `call` method does not rescue exceptions. If `Apartment::Tenant.switch` raises `TenantNotFound` (e.g., from HostHash), the exception propagates through the Rack stack. This is intentional: + +- Custom elevators handle errors by wrapping `super` in their own `call` override (as DynamicElevator does with rescue -> redirect). +- The `tenant_not_found_handler` config is an adapter-level hook, not a middleware-level one. +- Generic adding rescue logic would interfere with custom error handling in subclasses. + +Users who want middleware-level error handling should subclass Generic and override `call`. + +## Configuration Examples + +```ruby +# Subdomain with exclusions +Apartment.configure do |config| + config.elevator = :subdomain + config.elevator_options = { excluded_subdomains: %w[www api admin] } +end + +# Header (trusted infrastructure) +Apartment.configure do |config| + config.elevator = :header + config.elevator_options = { header: 'X-Tenant-Id', trusted: true } +end + +# HostHash (explicit mapping) +Apartment.configure do |config| + config.elevator = :host_hash + config.elevator_options = { hash: { 'acme.com' => 'acme', 'widgets.io' => 'widgets' } } +end + +# Custom elevator class (e.g., DynamicElevator) +Apartment.configure do |config| + config.elevator = DynamicElevator # pass class directly, not symbol +end +``` + +When `elevator` is a class (not a symbol), `resolve_elevator_class` passes it through (see Railtie Changes above). + +## Subclassing Contract + +Custom elevators (like DynamicElevator) rely on: + +1. `parse_tenant_name(request)` — overridable, returns tenant string or nil +2. `call(env)` — overridable, allows wrapping `super` with error handling +3. `subdomains(host)` / `subdomain(host)` — overridable on Subdomain/FirstSubdomain for custom host parsing + +These methods remain the public extension points. No method signature changes from v3. + +## Testing + +### Unit tests (`spec/unit/elevators/`) + +No database required. Mock `Apartment::Tenant.switch` to verify tenant resolution. + +| File | Coverage | +|------|----------| +| `generic_spec.rb` | Proc processor, subclass processor, nil tenant falls through, switch called with block | +| `subdomain_spec.rb` | Subdomain extraction, excluded_subdomains filtering, IP rejection, international TLDs | +| `first_subdomain_spec.rb` | Nested subdomain extraction, nil handling | +| `domain_spec.rb` | SLD extraction, www stripping, blank host | +| `host_spec.rb` | Full hostname, ignored_first_subdomains stripping | +| `host_hash_spec.rb` | Hash lookup, raises TenantNotFound on missing host | +| `header_spec.rb` | Header extraction, Rack env key normalization, missing header returns nil | + +### Integration test + +Extend `spec/integration/v4/request_lifecycle_spec.rb` with a Header elevator scenario (swap middleware config, send request with tenant header, verify context). + +## Files + +### New +- `spec/unit/elevators/generic_spec.rb` +- `spec/unit/elevators/subdomain_spec.rb` +- `spec/unit/elevators/first_subdomain_spec.rb` +- `spec/unit/elevators/domain_spec.rb` +- `spec/unit/elevators/host_spec.rb` +- `spec/unit/elevators/host_hash_spec.rb` +- `spec/unit/elevators/header_spec.rb` +- `lib/apartment/elevators/header.rb` + +### Modified +- `lib/apartment/elevators/generic.rb` — add `**_options` splat, `NotImplementedError` +- `lib/apartment/elevators/subdomain.rb` — constructor keyword args, remove class-level setters +- `lib/apartment/elevators/first_subdomain.rb` — fix double-super call +- `lib/apartment/elevators/domain.rb` — no change +- `lib/apartment/elevators/host.rb` — constructor keyword args, remove class-level setters +- `lib/apartment/elevators/host_hash.rb` — constructor keyword args +- `lib/apartment/railtie.rb` — `**opts`, Header trust warning diff --git a/docs/designs/v4-migrations.md b/docs/designs/v4-migrations.md new file mode 100644 index 00000000..2b145e5c --- /dev/null +++ b/docs/designs/v4-migrations.md @@ -0,0 +1,327 @@ +# v4 Migrations — Design Spec + +## Overview + +This spec covers the Migrator, schema dumper patch, schema cache generation, and rake/Thor task integration for Apartment v4. The Migrator orchestrates ActiveRecord migrations across all tenants with optional thread-based parallelism. + +**Primary goal:** `Apartment::Migrator` migrates the primary database and all tenant schemas/databases, with per-tenant result tracking and thread parallelism. + +**Secondary goals:** +- Fix Rails 8.1 `public.` prefix regression in `schema.rb` dumps +- Support both `schema.rb` and `structure.sql` as schema formats +- Provide schema cache generation (single canonical or per-tenant) +- Wire into `db:migrate:DBNAME` for zero-config defaults while keeping `apartment:migrate` for full control + +## Context & Motivation + +### Why Tenant.switch, not standalone pools? + +The original design proposed a standalone `PoolManager` with ephemeral per-tenant pools. During implementation, we discovered that Rails' migration machinery hardcodes `DatabaseTasks.migration_connection` → `ActiveRecord::Base.lease_connection`, bypassing any standalone pool. Three approaches were tried (standalone pools, handler registration with unique `owner_name:`, thread-local `ConnectionHandler` swaps) — all failed due to this coupling. + +v4's `ConnectionHandling` patch already solves the routing problem: `Tenant.switch` sets `Current.tenant`, and the patch intercepts `AR::Base.connection_pool` to return the tenant's pool. Since `AR::Base.lease_connection` goes through `connection_pool`, Rails' migration runner automatically uses the correct tenant connection. No standalone pools, handler swaps, or monkey-patches needed. + +**RBAC credential separation** (`migration_db_config`) is deferred to Phase 5. The challenge: `Tenant.switch` uses runtime pools with `app_user` credentials, but DDL operations need `db_manager`. Phase 5 will address this by allowing the adapter to resolve credentials from a database.yml config name (e.g., `:db_manager`) during migration context, following the pattern established in CampusESP's `database.yml` where `db_manager` is a separate config with elevated credentials. + +### Why threads only? + +Migrations are IO-bound (DDL statements sent to PostgreSQL, waiting for responses). Ruby releases the GVL during IO syscalls, so threads achieve real parallelism for this workload. CampusESP's `release.rb` already validates this pattern at scale with `Parallel.each(..., in_threads: 8)` across 500+ schemas on CodeBuild (8 vCPU, 16 GiB). + +Process-based parallelism (`fork`) is dropped from the design: +- `fork` + ActiveRecord connections is a known footgun (pools don't survive fork) +- Memory doubles per worker (CoW degrades as Ruby's GC touches pages) +- Result marshaling across processes requires pipes/sockets +- Ractors are incompatible with ActiveRecord's connection model (byroot, Feb 2025) +- Fiber schedulers add complexity without benefit when parallelism is across tenants (each thread's work is inherently serial) + +### Why no per-schema advisory locks? + +Rails' advisory lock for migrations uses a single database-scoped lock ID. Per-schema advisory locks (incorporating `current_schema` into the lock ID) were proposed in [rails/rails#43500](https://github.com/rails/rails/pull/43500) but rejected by Rails core. The reasons apply to Apartment: + +1. **Incomplete guarantee.** Advisory locks scoped to a schema don't protect against cross-schema DDL. A migration that references `public.shared_table` is not covered by a lock on `acme`. The lock's scope doesn't match the operation's scope (matthewd, Rails core). +2. **Operational concern, not a locking concern.** Duplicate Migrator invocations are prevented by deploy pipeline serialization (e.g., CodePipeline), not by in-process locks. +3. **Recoverable failure mode.** If two processes do race, `PG::UniqueViolation` on DDL is a clear error, not silent data corruption. +4. **False confidence.** Users who see "advisory lock acquired" may assume full isolation, when they don't have it. + +The v4 Migrator disables advisory locks during parallel migration and documents the operational requirement: only one Migrator should run at a time per database. The `schema_migrations` table serves as the last-resort idempotency check. + +See: [rails-on-services/apartment#298](https://github.com/rails-on-services/apartment/issues/298), [rails/rails#43500](https://github.com/rails/rails/pull/43500#issuecomment-2447817077) + +## Migrator Architecture + +### `Apartment::Migrator` + +Orchestrator that delegates to `Tenant.switch` for connection routing and Rails' standard migration machinery for DDL execution. + +**Constructor:** + +```ruby +Apartment::Migrator.new( + threads: 8, # 0 = sequential (default) +) +``` + +- `threads`: concurrency level. `0` means sequential (safe default for development, CI debugging). + +**Execution flow:** + +``` +Migrator#run + ├── Phase 1: Migrate primary database (blocking) + │ Uses AR::Base.connection_pool directly (default connection) + │ Checks for pending migrations; skips if up-to-date + │ Aborts entire run on failure (tenants are never touched) + ├── Phase 2: Migrate tenants (parallel or sequential) + │ ├── Resolve tenants from tenants_provider + │ ├── For each tenant: + │ │ ├── Tenant.switch(tenant) — ConnectionHandling patch routes AR::Base to tenant's pool + │ │ ├── Disable advisory locks on leased connection + │ │ ├── AR::Base.connection_pool.migration_context.migrate + │ │ ├── Record Result (success/failure + timing) + │ │ └── Tenant.switch ensure block restores previous tenant + │ └── Collect Results + └── Return MigrationRun (summary + per-tenant results) +``` + +Schema dump (Phase 3) is handled by the rake task after `Migrator#run` returns, respecting `ActiveRecord.dump_schema_after_migration`. Schema cache generation (Phase 4) is deferred. + +### Connection Routing + +The Migrator does not create its own pools. For each tenant, `Tenant.switch` sets `Current.tenant`, and the `ConnectionHandling` patch intercepts `AR::Base.connection_pool` to return the tenant's pool from v4's runtime `PoolManager`. Rails' migration runner calls `DatabaseTasks.migration_connection` → `AR::Base.lease_connection` → `connection_pool.lease_connection`, which resolves to the tenant's pool automatically. + +This means migrations use the same pools (and credentials) as the runtime application. RBAC credential separation (`migration_db_config`) — where DDL runs with `db_manager` credentials instead of `app_user` — is deferred to Phase 5. The design point: the adapter's connection config resolution could accept an optional credential overlay from a database.yml entry (e.g., `:db_manager`), following CampusESP's pattern where `db_manager` is a separate config with elevated credentials. + +### Thread Coordination + +Work-stealing via `Queue` (stdlib, thread-safe). Each thread pops a tenant from the queue, migrates it, records the result, and pops the next. Results collected in a `Concurrent::Array` (requires `concurrent-ruby`, already a v4 dependency via `PoolManager`). No shared mutable state beyond these two thread-safe structures. + +```ruby +work_queue = Queue.new +tenants.each { |t| work_queue << t } +threads.times { work_queue << :done } # poison pills + +results = Concurrent::Array.new + +workers = threads.times.map do + Thread.new do + while (tenant = work_queue.pop) != :done + result = migrate_tenant(tenant) + results << result + end + end +end + +workers.each(&:join) +``` + +## Result Tracking + +### `Apartment::Migrator::Result` + +Value object per tenant migration, using `Data.define` (Ruby 3.2+, immutable): + +```ruby +Result = Data.define( + :tenant, # String — tenant name + :status, # Symbol — :success, :failed, :skipped + :duration, # Float — seconds (monotonic clock) + :error, # Exception or nil + :versions_run # Array — migration versions applied +) +``` + +- `:skipped` for tenants already up-to-date (no pending migrations) +- `versions_run` gives visibility into what changed per tenant + +### `Apartment::Migrator::MigrationRun` + +Aggregate returned by `Migrator#run`: + +```ruby +MigrationRun = Data.define( + :results, # Array + :total_duration, # Float — wall clock for entire run + :threads # Integer — concurrency used +) do + def succeeded = results.select { _1.status == :success } + def failed = results.select { _1.status == :failed } + def skipped = results.select { _1.status == :skipped } + def success? = failed.empty? +end +``` + +**Reporting:** `MigrationRun#summary` returns a formatted string for logging. The Migrator emits `ActiveSupport::Notifications` events (`migrate_tenant.apartment`) per tenant, following v4's `verb.namespace` convention (e.g., `switch.apartment`, `create.apartment`). + +**Failure handling:** A failed tenant does not halt the run. All tenants are attempted. The caller inspects `migration_run.success?` and decides the response (raise, log, alert). + +## Schema Dumper Patch + +### Problem + +Rails 8.1 added schema-qualified table names to `schema.rb` output (e.g., `create_table "public.users"`). When loaded into a tenant schema, tables land in `public` instead of the tenant's schema. + +### Solution + +Patch `ActiveRecord::SchemaDumper` to strip the `public.` prefix during dump. Applied conditionally (Rails >= 8.1 only). The dumped `schema.rb` is schema-agnostic — works for both public and tenant schemas. + +`structure.sql` behavior depends on `pg_dump` flags. Rails' `db:structure:dump` may produce schema-qualified DDL (e.g., `CREATE TABLE public.users`) depending on the Rails version and `pg_dump` configuration. If Rails 8.1 changed `schema.rb` output to include `public.` prefixes, the `structure.sql` dump should also be verified. The Migrator should control or document the expected `pg_dump` flags to ensure clean output. If `structure.sql` contains `public.` prefixes, the same stripping logic may need to apply during schema load (but not during dump, since the dump reflects the actual database state). + +### `include_schemas_in_dump` + +This option already exists in `PostgresqlConfig` (accessed via `configure_postgres`). It specifies non-public schemas whose tables should retain their schema prefix in `schema.rb` dumps (e.g., `%w[ext shared]`). Tables in `public` always get their prefix stripped by the patch. + +```ruby +Apartment.configure do |c| + c.configure_postgres do |pg| + pg.include_schemas_in_dump = %w[ext shared] + end +end +``` + +Default: `[]` (strip all `public.` prefixes; no non-public schemas retained). + +## Schema Cache + +### Default: Single Canonical Cache + +All tenants share the canonical public schema cache. After the Migrator completes: +1. Public schema was migrated first +2. Schema dump runs against public (produces `schema.rb` or `structure.sql`) +3. `db:schema:cache:dump` produces one `schema_cache.yml` +4. All tenants use this file at runtime (shared schema structure) + +### Optional: Per-Tenant Cache + +`schema_cache_per_tenant: true` generates a cache file per tenant after migrating it, stored as `db/schema_cache_.yml`. Use case: tenants on different shards that may diverge in schema over time. + +When `schema_cache_per_tenant` is enabled, `public.` prefixes are retained in per-tenant cache files (each cache reflects its actual schema context). Prefix stripping is only needed for the shared canonical mode. + +| Mode | Schema dump | Schema cache | `public.` prefix | +|------|------------|--------------|------------------| +| Default (shared) | Single file, schema-agnostic | Single `schema_cache.yml` from public | Stripped | +| Per-tenant | Single file, schema-agnostic | Per-tenant `schema_cache_.yml` | Retained in cache | + +Schema cache generation is opt-in. The Migrator does not generate caches by default; callers (rake tasks, `release.rb`) control when caching runs. + +## Task Integration + +### Three layers (most specific to least) + +1. **Thor CLI** (primary, most options): `apartment migrate --threads 8 --db-config db_manager` — Phase 6 deliverable, not implemented in Phase 4 +2. **`apartment:migrate` rake** (thin wrapper): delegates to Migrator with config defaults, supports `VERSION=` env var +3. **`db:migrate:DBNAME` enhancement** (Rails-native hook): when Apartment is loaded, enhances the task to also run `apartment:migrate` after the base migration + +Thor commands are the canonical interface (Phase 6). Rake tasks are convenience wrappers. The `db:migrate:DBNAME` hook provides zero-config defaults. + +### Rake tasks (Phase 4) + +``` +apartment:migrate # uses config defaults +apartment:migrate VERSION=20260401 # specific version +apartment:rollback[2] # rollback 2 steps (matches existing v4.rake argument syntax) +``` + +These delegate to the Migrator. The existing v4.rake tasks are updated to wire through the Migrator instead of direct adapter calls. The rake task checks `ActiveRecord.dump_schema_after_migration` before invoking the Migrator's schema dump phase (the module-level accessor, not the removed `ActiveRecord::Base` method; see [ros-apartment#342](https://github.com/rails-on-services/apartment/pull/342)). + +### `db:migrate:DBNAME` enhancement + +When Apartment is loaded, the Railtie enhances `db:migrate:primary` (or whatever the primary database config is named) to also invoke `apartment:migrate`. This provides Rails-native compatibility without requiring users to know about Apartment's task namespace. + +The enhancement must not interfere with other database configs (e.g., `db:migrate:ddl_workspace`). + +**Idempotency:** If `db:migrate:primary` triggers `apartment:migrate`, and the user then runs `apartment:migrate` directly, the Migrator checks for pending migrations per tenant. Tenants already up-to-date return `:skipped`. Phase 1 (primary database migration) also checks for pending migrations before executing. Running the Migrator twice is safe and fast. + +### Composability with existing Thor tasks + +CampusESP uses `schema:pristine` and `schema:baseline` Thor tasks for schema management. These operate on a separate `ddl_workspace` database config and use `db:migrate:ddl_workspace`. The Migrator's `db:migrate:primary` enhancement is scoped to the primary config only; these tasks are unaffected. + +The baseline loader migration uses `connection_db_config` to resolve credentials for `psql`. Phase 5 will add `migration_db_config` support so the Migrator can use `db_manager` credentials for DDL operations, matching the pattern these Thor tasks already use. + +## Configuration + +New config options added in Phase 4: + +```ruby +Apartment.configure do |c| + # Migrator + c.parallel_migration_threads = 8 # Integer — 0 = sequential (default) + + # PostgreSQL-specific (already exists in PostgresqlConfig) + c.configure_postgres do |pg| + pg.include_schemas_in_dump = %w[ext shared] # schemas that retain prefix in schema.rb dumps + end +end +``` + +**Removed from Config:** `parallel_strategy` and `VALID_PARALLEL_STRATEGIES` are removed. The `parallel_migration_threads` attribute (already exists, default `0`) is the sole parallelism control. Threads are the only parallelism primitive. + +**Deferred to Phase 5:** +- `migration_db_config` — Symbol referencing a database.yml config for DDL credentials (e.g., `:db_manager`). Requires adapter-level support for credential overlay within `Tenant.switch`. +- `schema_cache_per_tenant` — Boolean for per-tenant cache files vs single canonical. +- `app_role` — PostgreSQL RBAC privilege grants on tenant create. + +## PendingMigrationError + +Development-only runtime check. When a tenant's connection pool is created and the tenant has pending migrations, raise `Apartment::PendingMigrationError`. Gated behind `Rails.env.local?` (covers development and test). In production, migrations should have already run in the deploy pipeline. + +Deferred to Phase 5 (runtime concern, not a Migrator concern). Requires adding `Apartment::PendingMigrationError` to `errors.rb` in that phase. + +## Testing Strategy + +### Unit tests (no database required) + +All Migrator core logic testable with mocks/stubs: + +- Thread coordination (work queue, sequential mode, empty tenant list) +- Result tracking (`MigrationRun#success?`, `#summary`, all status states) +- Tenant switching (verifies `Tenant.switch` called for each tenant) +- Advisory lock disabling (verifies `@advisory_locks_enabled` set on leased connection) +- Primary abort on failure (verifies early return, tenants not attempted) +- Schema dumper prefix stripping and version gating +- Failure isolation (one tenant's failure doesn't halt others) + +### Integration tests (real databases) + +Against PostgreSQL (schema-based) and SQLite (file-based): + +- End-to-end migrate: create tenants, add migration, run Migrator, verify table exists in each schema +- Parallel correctness: 4+ tenants with `threads: 2`, verify no cross-tenant leakage, all tenants migrated +- Partial failure: one tenant has broken migration, others succeed, verify `MigrationRun` reflects both +- Schema dump: run Migrator, verify `schema.rb` has no `public.` prefix; verify `structure.sql` is clean +- Idempotency: run Migrator twice, second run returns all `:skipped` results +- Version targeting: `VERSION=` env var passed through to `context.migrate` + +**Deferred to Phase 5:** +- RBAC flow: `migration_db_config: :db_manager`, verify DDL runs with elevated credentials, runtime pools use `app_user` (PostgreSQL only) + +### Out of scope + +- Thor CLI tests (Phase 6) +- Per-tenant schema cache integration tests (unit coverage sufficient) +- MySQL RBAC integration (MySQL doesn't have schema-based tenancy; RBAC concern is PG-specific in our test matrix) + +## Files + +``` +lib/apartment/ +├── migrator.rb # NEW — Migrator, Result, MigrationRun +├── schema_dumper_patch.rb # NEW — Rails 8.1 public. prefix stripping +├── config.rb # MODIFY — new config keys +├── tasks/v4.rake # MODIFY — wire through Migrator +├── railtie.rb # MODIFY — db:migrate:DBNAME enhancement hook + +spec/unit/ +├── migrator_spec.rb # NEW +├── schema_dumper_patch_spec.rb # NEW + +spec/integration/v4/ +├── migrator_integration_spec.rb # NEW +``` + +## Out of Scope (Phase 4) + +- Thor CLI commands (Phase 6) +- RBAC privilege management / `app_role` config (Phase 5) +- `PendingMigrationError` runtime check (Phase 5) +- Per-tenant connection configs / multi-shard support (future) +- `ARTENANT=` single-tenant targeting (future) +- Per-schema advisory locks (rejected; see rationale above) diff --git a/docs/designs/v4-phase5-rbac-roles-schema-cache.md b/docs/designs/v4-phase5-rbac-roles-schema-cache.md new file mode 100644 index 00000000..57bc9adf --- /dev/null +++ b/docs/designs/v4-phase5-rbac-roles-schema-cache.md @@ -0,0 +1,872 @@ +# Phase 5: Role-Aware Connections, RBAC, Schema Cache, Pending Migration Check + +## Overview + +Phase 5 makes Apartment v4's `ConnectionHandling` patch role-aware, enabling RBAC credential separation, replica routing, and custom role composition with tenant switching — all using Rails' native `connected_to(role:)` mechanism. It also adds per-tenant schema cache generation and a development-time pending migration check. + +**Primary goal:** `connected_to(role: :reading) { Tenant.switch('acme') { ... } }` and `connected_to(role: :db_manager) { Tenant.switch('acme') { ... } }` create tenant pools with the correct base config for the active role, not hardcoded to the primary connection. + +**Secondary goals:** +- Migrator uses configurable `migration_role` for elevated DDL credentials +- Automatic RBAC privilege grants on tenant creation (`app_role`) +- Optional per-tenant schema cache files +- Development-only `PendingMigrationError` on tenant pool creation + +## Context & Motivation + +### The Bug: ConnectionHandling Ignores Role + +Phase 4's `ConnectionHandling#connection_pool` resolves tenant configs from the adapter's `base_config` — always the primary connection config (`@connection_config`). It passes `ActiveRecord::Base.current_role` to `establish_connection`, so the pool is *registered* under the correct role, but the *config* (host, username, password) always comes from the primary. + +This means: +- `connected_to(role: :reading) { Tenant.switch('acme') { ... } }` creates a tenant pool registered under `:reading` but pointing at the primary host, not the replica +- `connected_to(role: :db_manager) { Tenant.switch('acme') { ... } }` creates a tenant pool with `app_user` credentials, not `db_manager` + +These patterns are common in production multi-tenant apps: + +```ruby +# DDL under elevated role +ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(schema) do + # expects db_manager credentials, actually gets app_user + end +end + +# Replica routing for read-heavy queries +ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true) do + # expects replica host, actually gets primary +end +``` + +Both patterns are silently broken. Fixing `ConnectionHandling` to be role-aware resolves RBAC credential separation, replica routing, and arbitrary custom role composition in one change. + +### Why Roles, Not Credential Overlay + +The original Phase 5 plan proposed `Current.credential_overlay` — a fiber-safe attribute that `ConnectionHandling` would check when creating pools, merging elevated credentials from a database.yml entry. Research during brainstorming revealed this is unnecessary because: + +1. Production apps already register roles via `connects_to` in `ApplicationRecord`: + +```ruby +connects_to database: { + writing: :primary, + reading: :primary_replica, + db_manager: :db_manager, +} +``` + +2. Rails' `ConnectionHandler` stores pools in a `(connection_name, role, shard)` lookup structure. Each `(role, shard)` pair gets an independent `db_config`. Roles can have completely different host, port, username, password — they're fully independent configs. + +3. `connected_to(role:)` pushes onto a per-fiber `connected_to_stack` (via `IsolatedExecutionState`). `current_role` reads from the stack top, falling back to `ActiveRecord.writing_role` (configurable via `config.active_record.writing_role`, defaults to `:writing`). + +4. Making `ConnectionHandling` resolve base config from the current role's default pool is a 5-line change. The credential overlay approach would have required a new `Current` attribute, merge logic, pool eviction after migration, and a separate pool key namespace — all unnecessary complexity. + +### Reference RBAC Architecture + +A typical production setup uses two PostgreSQL roles: +- `app_user` — runtime DML (SELECT, INSERT, UPDATE, DELETE). No DDL privileges. +- `db_manager` — inherits `app_user` via `GRANT app_user TO db_manager`. Has CREATE privilege on databases. Owns schemas and objects created during migrations. + +Both point at the same database with different credentials. `database.yml` maps them to separate named configs (`primary` and `db_manager`), registered as roles via `connects_to`. + +A privilege fixer service grants `app_user` access to tenant schemas after restore operations: +1. `GRANT USAGE ON SCHEMA` — schema visibility +2. `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES` — existing table access +3. `GRANT USAGE, SELECT ON ALL SEQUENCES` — sequence access for inserts +4. `ALTER DEFAULT PRIVILEGES ... ON TABLES` — future table access +5. `ALTER DEFAULT PRIVILEGES ... ON SEQUENCES` — future sequence access +6. `ALTER DEFAULT PRIVILEGES ... ON FUNCTIONS` — future function access + +The `ALTER DEFAULT PRIVILEGES` trap: these only fire for objects created by the named grantor role. If `db_manager` sets the defaults but a migration runs as `app_user`, the grants silently don't apply. The invariant that resolves this: **migration role = grantor role = schema owner**. + +## Role-Aware ConnectionHandling + +### Pool Key Format + +Pool keys change from `"#{tenant}"` to `"#{tenant}:#{role}"`. Always includes role — no special-casing for `:writing`. + +Examples: +- `"acme:writing"` — runtime pool with app_user credentials +- `"acme:reading"` — replica pool +- `"acme:db_manager"` — elevated credentials pool + +### Base Config Resolution + +`ConnectionHandling#connection_pool` calls `super` (the original, un-patched method) to get the default tenant's pool for `current_role`. Extracts the pool's `db_config.configuration_hash` as the base config. Passes it to the adapter via `base_config_override:`. + +```ruby +def connection_pool + tenant = Apartment::Current.tenant + cfg = Apartment.config + return super if tenant.nil? || cfg.nil? + return super if tenant.to_s == cfg.default_tenant.to_s + return super unless Apartment.pool_manager + + role = ActiveRecord::Base.current_role + pool_key = "#{tenant}:#{role}" + + Apartment.pool_manager.fetch_or_create(pool_key) do + default_pool = super + base = default_pool.db_config.configuration_hash.stringify_keys + + config = Apartment.adapter.validated_connection_config(tenant, base_config_override: base) + + prefix = cfg.shard_key_prefix + shard_key = :"#{prefix}_#{pool_key}" + + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + cfg.rails_env_name, + "#{prefix}_#{pool_key}", + config + ) + + pool = ActiveRecord::Base.connection_handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: role, + shard: shard_key + ) + + if check_pending_migrations?(pool) + raise Apartment::PendingMigrationError.new(tenant) + end + + pool + end +rescue Apartment::ApartmentError + raise +rescue StandardError => e + raise(Apartment::ApartmentError, + "Failed to resolve connection pool for tenant '#{tenant}': #{e.class}: #{e.message}") +end +``` + +### Adapter Interface Change + +`AbstractAdapter#validated_connection_config` and `resolve_connection_config` gain a `base_config_override:` / `base_config:` keyword: + +```ruby +# abstract_adapter.rb +def validated_connection_config(tenant, base_config_override: nil) + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: (base_config_override || base_config)['adapter'] + ) + resolve_connection_config(tenant, base_config: base_config_override || base_config) +end + +# Subclasses override with base_config: keyword +def resolve_connection_config(tenant, base_config: nil) + raise(NotImplementedError) +end +``` + +Each adapter's `resolve_connection_config` applies tenant-specific modifications on top of whatever base it receives: + +```ruby +# postgresql_schema_adapter.rb +def resolve_connection_config(tenant, base_config: nil) + config = base_config || self.base_config + persistent = Apartment.config.postgres_config&.persistent_schemas || [] + search_path = [tenant, *persistent].join(',') + config.merge('schema_search_path' => search_path) +end + +# postgresql_database_adapter.rb +def resolve_connection_config(tenant, base_config: nil) + config = base_config || self.base_config + config.merge('database' => environmentify(tenant)) +end + +# mysql2_adapter.rb (trilogy inherits) +def resolve_connection_config(tenant, base_config: nil) + config = base_config || self.base_config + config.merge('database' => environmentify(tenant)) +end + +# sqlite3_adapter.rb +def resolve_connection_config(tenant, base_config: nil) + config = base_config || self.base_config + db_dir = config['database'] ? File.dirname(config['database']) : 'db' + config.merge('database' => File.join(db_dir, "#{environmentify(tenant)}.sqlite3")) +end +``` + +This is an internal API change (only called by `ConnectionHandling`). v4 has no allegiance to v3's public API. + +### Tenant Name Colon Restriction + +The composite pool key format `"tenant:role"` uses `:` as a delimiter. Tenant names containing colons would produce ambiguous keys (e.g., `"foo:bar:writing"`). While `rpartition(':')` handles this correctly (splits on the last colon), adding `:` to `TenantNameValidator`'s common character blacklist eliminates the edge case entirely. Colons are invalid in PostgreSQL identifiers and MySQL database names anyway, so this restriction has no practical impact. + +### What This Enables + +| Caller pattern | Base config source | Tenant config | +|---|---|---| +| `Tenant.switch('acme') { ... }` | Primary (`:writing` role) | Primary + search_path | +| `connected_to(role: :reading) { Tenant.switch('acme') { ... } }` | Replica | Replica + search_path | +| `connected_to(role: :db_manager) { Tenant.switch('acme') { ... } }` | db_manager | db_manager + search_path | +| `connected_to(role: :cloning) { Tenant.switch('acme') { ... } }` | cloning_workspace | cloning_workspace + search_path | + +Each combination gets its own pool, keyed by `"tenant:role"`, with the correct host/port/username/password for the active role. + +### Pool Lifecycle: Drop, Eviction, and Deregistration + +The pool key format change from `"#{tenant}"` to `"#{tenant}:#{role}"` cascades through three subsystems that manage pool lifecycle: `AbstractAdapter#drop`, `PoolReaper`, and `Apartment.deregister_shard`. All must handle composite keys correctly. + +**`PoolManager#remove_tenant(tenant)` (new method):** Removes all pools for a tenant across all roles. Iterates pool keys matching `"#{tenant}:"` prefix. Returns an array of removed pools. Used by `AbstractAdapter#drop` and test teardown. + +```ruby +# pool_manager.rb +def remove_tenant(tenant) + prefix = "#{tenant}:" + removed = [] + @pools.each_key do |key| + next unless key.start_with?(prefix) + pool = remove(key) + removed << [key, pool] if pool + end + removed +end +``` + +**`AbstractAdapter#drop` update:** Currently calls `pool_manager.remove(tenant.to_s)` (single key). Updated to call `pool_manager.remove_tenant(tenant)` (removes all role variants). Deregisters each removed pool's shard from AR's ConnectionHandler: + +```ruby +def drop(tenant) + drop_tenant(tenant) + removed_pools = Apartment.pool_manager&.remove_tenant(tenant) || [] + removed_pools.each do |pool_key, pool| + begin + pool&.disconnect! if pool.respond_to?(:disconnect!) + rescue StandardError => e + warn "[Apartment] Pool disconnect failed for '#{pool_key}': #{e.class}: #{e.message}" + end + Apartment.deregister_shard(pool_key) + end + Instrumentation.instrument(:drop, tenant: tenant) +end +``` + +**`Apartment.deregister_shard` update:** Currently builds the shard key from raw tenant name and uses `current_role`. Updated to accept the composite pool key (which already contains the role) and extract the role from it: + +```ruby +def deregister_shard(pool_key) + return unless @config && defined?(ActiveRecord::Base) + + # pool_key is "tenant:role" — extract the role for AR deregistration + _tenant, _, role_str = pool_key.to_s.rpartition(':') + role = role_str.empty? ? ActiveRecord.writing_role : role_str.to_sym + + shard_key = :"#{@config.shard_key_prefix}_#{pool_key}" + ActiveRecord::Base.connection_handler.remove_connection_pool( + 'ActiveRecord::Base', + role: role, + shard: shard_key + ) +rescue StandardError => e + warn "[Apartment] Failed to deregister AR pool for #{pool_key}: #{e.class}: #{e.message}" +end +``` + +**`PoolReaper` default tenant guard:** Currently checks `tenant == @default_tenant`. Updated to check the tenant prefix: + +```ruby +def default_tenant_pool?(pool_key) + pool_key.start_with?("#{@default_tenant}:") +end + +# In evict_idle and evict_lru: +next if default_tenant_pool?(tenant) +``` + +**`PoolManager#evict_by_role(role)` (new convenience method):** Removes all pools whose key ends with `:#{role}`. Used by rake/Thor post-migration cleanup: + +```ruby +def evict_by_role(role) + suffix = ":#{role}" + removed = [] + @pools.each_key do |key| + next unless key.end_with?(suffix) + pool = remove(key) + removed << [key, pool] if pool + end + removed +end +``` + +## Migrator: `migration_role` + +### Config + +```ruby +Apartment.configure do |c| + c.migration_role = :db_manager # Symbol, default nil (uses current role) +end +``` + +### Integration + +`with_migration_role` wraps both `migrate_primary` and each `migrate_tenant` call independently. This is necessary because `connected_to_stack` is per-fiber — worker threads in parallel migration need their own role context. + +```ruby +def run + start = monotonic_now + + primary_result = with_migration_role { migrate_primary } + + if primary_result.status == :failed + return MigrationRun.new( + results: [primary_result], + total_duration: monotonic_now - start, + threads: @threads + ) + end + + tenants = Apartment.config.tenants_provider.call + tenant_results = if @threads.positive? + run_parallel(tenants) + else + run_sequential(tenants) + end + + all_results = [primary_result, *tenant_results].compact + + MigrationRun.new( + results: all_results, + total_duration: monotonic_now - start, + threads: @threads + ) +end + +def migrate_tenant(tenant) + start = monotonic_now + with_migration_role do + Apartment::Tenant.switch(tenant) do + # ... migration logic (unchanged from Phase 4) + end + end +rescue StandardError => e + Result.new(tenant: tenant, status: :failed, duration: monotonic_now - start, error: e, versions_run: []) +end + +private + +def with_migration_role(&) + role = Apartment.config.migration_role + role ? ActiveRecord::Base.connected_to(role: role, &) : yield +end +``` + +### Thread Safety + +`connected_to` pushes onto a per-fiber `connected_to_stack` (uses `IsolatedExecutionState`). Each worker thread gets its own stack. The `with_migration_role` call inside `migrate_tenant` means each thread independently sets its role context. No shared mutable state. + +### Post-Migration Pool Lifecycle + +Tenant pools created under `:db_manager` (e.g., `"acme:db_manager"`) remain in `pool_manager` after migration. For deployments with hundreds of tenants, this means hundreds of idle `db_manager` connections for `pool_idle_timeout` seconds (default 300s). This is wasteful. + +The Migrator calls `pool_manager.evict_by_role(migration_role)` in an `ensure` block after `run` completes. This immediately disconnects and removes all migration-role pools, deregistering them from AR's ConnectionHandler. Runtime pools (`:writing`) are unaffected. + +```ruby +def run + # ... migration logic +ensure + evict_migration_pools +end + +def evict_migration_pools + role = Apartment.config.migration_role + return unless role && Apartment.pool_manager + + Apartment.pool_manager.evict_by_role(role).each do |pool_key, _pool| + Apartment.deregister_shard(pool_key) + end +end +``` + +## RBAC Privilege Grants: `app_role` + +### Config + +```ruby +Apartment.configure do |c| + c.app_role = 'app_user' # String, callable, or nil (default) +end +``` + +- **String**: built-in engine-appropriate grants for that role name +- **Callable** `(tenant, connection)`: custom grant logic (escape hatch) +- **nil**: no grants + +### Execution Point + +Inside `AbstractAdapter#create`, after `create_tenant`, before `import_schema`: + +```ruby +def create(tenant) + TenantNameValidator.validate!(tenant, ...) + run_callbacks(:create) do + create_tenant(tenant) + grant_tenant_privileges(tenant) + import_schema(tenant) if Apartment.config.schema_load_strategy + seed(tenant) if Apartment.config.seed_after_create + Instrumentation.instrument(:create, tenant: tenant) + end +end + +private + +def grant_tenant_privileges(tenant) + app_role = Apartment.config.app_role + return unless app_role + + conn = ActiveRecord::Base.connection + if app_role.respond_to?(:call) + app_role.call(tenant, conn) + else + grant_privileges(tenant, conn, app_role) + end +end + +# Default no-op; PG and MySQL adapters override +def grant_privileges(tenant, connection, role_name) + # no-op +end +``` + +### PostgresqlSchemaAdapter Grants + +Six statements mirroring `PgSchema::PrivilegeFixer`: + +```ruby +def grant_privileges(tenant, connection, role_name) + quoted_schema = connection.quote_table_name(tenant) + quoted_role = connection.quote_table_name(role_name) + + connection.execute("GRANT USAGE ON SCHEMA #{quoted_schema} TO #{quoted_role}") + + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA #{quoted_schema} TO #{quoted_role}" + ) + + connection.execute( + "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA #{quoted_schema} TO #{quoted_role}" + ) + + # ALTER DEFAULT PRIVILEGES without FOR ROLE uses the current user. + # When create runs inside connected_to(role: :db_manager), the + # current user is db_manager — the schema owner and migration runner. + # This enforces the invariant: migration role = grantor role = schema owner. + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO #{quoted_role}" + ) + + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT USAGE, SELECT ON SEQUENCES TO #{quoted_role}" + ) + + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT EXECUTE ON FUNCTIONS TO #{quoted_role}" + ) +end +``` + +### Mysql2Adapter Grants + +Single statement: + +```ruby +def grant_privileges(tenant, connection, role_name) + db_name = environmentify(tenant) + quoted_role = connection.quote(role_name) + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON `#{db_name}`.* TO #{quoted_role}@'%'" + ) +end +``` + +Note: MySQL has no `quote_table_name` equivalent for role identifiers. `connection.quote` is used for the role name. Both `db_name` and `role_name` come from trusted config (not user input), but quoting is applied defensively. + +### PostgresqlDatabaseAdapter + +Database-per-tenant PG uses the `public` schema within each database. Grants operate on the database level, not schema level: + +```ruby +def grant_privileges(tenant, connection, role_name) + db_name = environmentify(tenant) + quoted_role = connection.quote_table_name(role_name) + connection.execute("GRANT CONNECT ON DATABASE #{connection.quote_table_name(db_name)} TO #{quoted_role}") + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO #{quoted_role}" + ) + connection.execute( + "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public " \ + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public " \ + "GRANT USAGE, SELECT ON SEQUENCES TO #{quoted_role}" + ) +end +``` + +Note: `GRANT CONNECT ON DATABASE` must run on the server-level connection (before switching into the tenant database). The table/sequence/default privilege grants run after switching into the tenant database. Implementation must handle this ordering — the `GRANT CONNECT` runs in `create_tenant` context (connected to the default database), while the remaining grants run inside `Tenant.switch(tenant)`. If the ordering is too complex, this can be deferred to the callable escape hatch. The built-in default for `PostgresqlDatabaseAdapter` may start as a no-op with documentation recommending the callable for database-per-tenant RBAC. + +### Sqlite3Adapter + +No override needed — inherits the no-op from `AbstractAdapter`. + +### Key Invariant + +**Migration role = grantor role = schema owner.** This holds because: +1. `Tenant.create` is called inside `connected_to(role: :db_manager)` (recommended pattern) +2. `CREATE SCHEMA` runs as db_manager — db_manager owns the schema +3. `ALTER DEFAULT PRIVILEGES` (no `FOR ROLE`) uses current user — db_manager is the grantor +4. Migrations run under `migration_role: :db_manager` — db_manager creates tables +5. Default privileges fire because the table creator (db_manager) matches the grantor + +If a user doesn't use `connected_to(role: :db_manager)` when calling `Tenant.create`, the grants still execute (as whatever user is connected), but `ALTER DEFAULT PRIVILEGES` applies to that user. This is correct: the grantor is whoever creates objects. The invariant is self-enforcing. + +### Callable Escape Hatch + +For non-standard privilege models: + +```ruby +Apartment.configure do |c| + c.app_role = ->(tenant, conn) { + conn.execute("GRANT USAGE ON SCHEMA #{conn.quote_table_name(tenant)} TO custom_role") + conn.execute("GRANT SELECT ON ALL TABLES IN SCHEMA #{conn.quote_table_name(tenant)} TO readonly_role") + } +end +``` + +## Schema Cache: `schema_cache_per_tenant` + +### Config + +```ruby +Apartment.configure do |c| + c.schema_cache_per_tenant = true # Boolean, default false +end +``` + +### Generation + +Explicit via `Apartment::SchemaCache` module: + +```ruby +module Apartment + module SchemaCache + module_function + + def dump(tenant) + path = cache_path_for(tenant) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.schema_cache.dump_to(path) + end + path + end + + def dump_all + Apartment.config.tenants_provider.call.map { |t| dump(t) } + end + + def cache_path_for(tenant) + base = defined?(Rails) && Rails.root ? Rails.root.join('db') : Pathname.new('db') + base.join("schema_cache_#{tenant}.yml").to_s + end + end +end +``` + +### Loading + +When `schema_cache_per_tenant` is enabled and `ConnectionHandling` creates a new tenant pool, it checks for a tenant-specific cache file. If present, loads it into the pool's schema cache. If absent, the pool uses the canonical `db/schema_cache.yml` via normal Rails behavior. + +```ruby +# Inside ConnectionHandling#connection_pool, after establish_connection +if Apartment.config.schema_cache_per_tenant + cache_path = Apartment::SchemaCache.cache_path_for(tenant) + if File.exist?(cache_path) + pool.schema_cache.load!(cache_path) + end +end +``` + +### Rake Task + +```ruby +namespace :apartment do + namespace :schema do + namespace :cache do + desc 'Dump schema cache for each tenant' + task dump: :environment do + paths = Apartment::SchemaCache.dump_all + paths.each { |p| puts "Dumped: #{p}" } + end + end + end +end +``` + +The Migrator does NOT auto-generate caches. Callers (rake tasks, `release.rb`, CI scripts) control when caching runs. + +Note: The schema cache dump/load API varies across Rails versions. `connection.schema_cache.dump_to(path)` is the Rails 7.x+ pattern. `pool.schema_cache.load!(path)` is used for loading. Implementation should verify the exact API surface for Rails 7.2/8.0/8.1 (our CI matrix) and use version-conditional code if needed. + +## PendingMigrationError + +### Config + +```ruby +Apartment.configure do |c| + c.check_pending_migrations = true # Boolean, default true +end +``` + +### Error Class + +```ruby +# errors.rb +class PendingMigrationError < ApartmentError + attr_reader :tenant + + def initialize(tenant = nil) + @tenant = tenant + super( + tenant ? "Tenant '#{tenant}' has pending migrations. Run apartment:migrate to update." + : 'Tenant has pending migrations. Run apartment:migrate to update.' + ) + end +end +``` + +### Check Location + +Inside `ConnectionHandling#connection_pool`, after `establish_connection`, gated behind three conditions: + +```ruby +def check_pending_migrations?(pool) + return false unless Apartment.config.check_pending_migrations + return false unless defined?(Rails) && Rails.env.local? + return false if Apartment::Current.migrating + + pool.migration_context.needs_migration? +end +``` + +### Migration Suppression + +`Current.migrating` (boolean attribute on `Apartment::Current`) is set by the Migrator to suppress the check during migration. Without this, the Migrator would raise on the first tenant with pending migrations — the exact scenario it's trying to fix. + +```ruby +# current.rb +class Current < ActiveSupport::CurrentAttributes + attribute :tenant, :previous_tenant, :migrating +end + +# migrator.rb +def run + Apartment::Current.migrating = true + # ... migration logic +ensure + Apartment::Current.migrating = false +end +``` + +Note: `CurrentAttributes` is per-fiber. In parallel migration, each worker thread has its own `Current` instance. The `migrating` flag must be set inside each worker thread. Since `with_migration_role` wraps each `migrate_tenant`, and `migrate_tenant` runs inside the worker thread, we set `Current.migrating = true` at the `Migrator#run` level for the main thread (covering `migrate_primary`) and rely on the fact that `CurrentAttributes` auto-resets for new fibers/threads. The worker threads call `migrate_tenant`, which enters `Tenant.switch` → `ConnectionHandling#connection_pool`. At this point, `Current.migrating` in the worker thread is `nil` (default). This is a problem. + +Solution: set `Current.migrating = true` inside `migrate_tenant`, before `Tenant.switch`: + +```ruby +def migrate_tenant(tenant) + start = monotonic_now + Apartment::Current.migrating = true + with_migration_role do + Apartment::Tenant.switch(tenant) do + # ... + end + end +rescue StandardError => e + Result.new(tenant: tenant, status: :failed, duration: monotonic_now - start, error: e, versions_run: []) +ensure + Apartment::Current.migrating = false +end +``` + +This ensures each worker thread has `migrating = true` in its own fiber-local `Current` before pool creation. + +### Latency Note + +The `needs_migration?` check queries `schema_migrations` — one database roundtrip per tenant on first pool creation. In development, this adds latency to the first request that touches a tenant. For apps with many tenants accessed in a single dev request, this could be noticeable. The check is development-only (`Rails.env.local?`) and runs once per tenant per boot (pool is cached), so the amortized cost is negligible. The config flag (`check_pending_migrations = false`) provides an escape hatch. + +## Configuration + +### New Config Keys + +```ruby +Apartment.configure do |c| + # Phase 5 + c.migration_role = :db_manager # Symbol — connects_to role for DDL (default: nil) + c.app_role = 'app_user' # String or callable — DML role to grant to (default: nil) + c.schema_cache_per_tenant = false # Boolean — per-tenant cache files (default: false) + c.check_pending_migrations = true # Boolean — raise in dev if pending (default: true) +end +``` + +### Validation + +In `Config#validate!`: +- `migration_role`: must be nil or a Symbol +- `app_role`: must be nil, a String, or respond to `:call` +- `schema_cache_per_tenant`: must be boolean +- `check_pending_migrations`: must be boolean + +### Freeze + +`app_role` is frozen if it's a String (callables are not frozen — they may close over mutable state, and freezing a proc/lambda is a no-op anyway). + +## Testing Strategy + +### Unit Tests (no database required) + +**ConnectionHandling role awareness:** +- Default role (`:writing`): pool key is `"tenant:writing"`, base config from primary +- Reading role: pool key is `"tenant:reading"`, base config from mock replica pool +- Custom role: pool key is `"tenant:db_manager"`, base config from mock db_manager pool +- Verify `super` is called to get the default pool for the current role +- Verify `base_config_override:` is passed to adapter + +**Pool lifecycle (composite keys):** +- `PoolManager#remove_tenant`: removes all role variants for a tenant +- `PoolManager#evict_by_role`: removes all pools for a given role +- `AbstractAdapter#drop`: calls `remove_tenant`, deregisters all removed pools +- `Apartment.deregister_shard`: extracts role from composite pool key +- `PoolReaper`: default tenant guard matches `"default:*"` prefix pattern +- `PoolReaper`: evicts `"acme:db_manager"` but not `"public:writing"` + +**Adapter base_config_override:** +- Each adapter: `resolve_connection_config` with and without `base_config:` keyword +- PostgresqlSchemaAdapter: merges search_path onto provided base +- Database adapters: merges database name onto provided base +- Sqlite3Adapter: merges file path onto provided base + +**Migrator with_migration_role:** +- `migration_role: nil`: no `connected_to` wrapper +- `migration_role: :db_manager`: wraps in `connected_to(role: :db_manager)` +- Verify `Current.migrating` is set/cleared around each `migrate_tenant` +- Verify `with_migration_role` is called for both `migrate_primary` and `migrate_tenant` +- Thread safety: verify role context is per-thread (mock `connected_to_stack`) + +**RBAC grants:** +- String `app_role`: verify adapter's `grant_privileges` called with tenant, connection, role_name +- Callable `app_role`: verify callable invoked with tenant, connection +- nil `app_role`: verify no grants +- PostgresqlSchemaAdapter: verify 6 SQL statements executed with correct quoting +- Mysql2Adapter: verify 1 SQL statement +- Sqlite3Adapter: verify no-op +- Grant ordering: verify grants run after `create_tenant`, before `import_schema` + +**Schema cache:** +- `dump(tenant)`: verify `Tenant.switch` called, `schema_cache.dump_to` called with correct path +- `dump_all`: verify iterates tenants_provider +- `cache_path_for`: verify path format `db/schema_cache_.yml` + +**PendingMigrationError:** +- Check fires when: `check_pending_migrations = true`, `Rails.env.local? = true`, `Current.migrating = false`, `needs_migration? = true` +- Check suppressed when: config disabled, non-local env, `Current.migrating = true`, no pending migrations +- Error message includes tenant name + +**Config validation:** +- `migration_role`: nil or Symbol accepted; other types rejected +- `app_role`: nil, String, callable accepted; other types rejected +- `schema_cache_per_tenant`: boolean only +- `check_pending_migrations`: boolean only + +### Integration Tests (real databases) + +**Role-aware connection (PostgreSQL):** +- Create tenant, switch under `:writing` role, verify pool uses primary config +- Switch under custom role with different config, verify pool uses that config +- Verify pool keys differ by role for same tenant +- Verify `prevent_writes: true` with `:reading` role propagates to tenant pool + +**RBAC flow (PostgreSQL):** +- Configure `app_role: 'app_user'`, create tenant as db_manager +- Verify `app_user` can SELECT/INSERT/UPDATE/DELETE in the tenant schema +- Verify `app_user` can access tables created after initial grants (default privileges) +- Verify `app_user` cannot CREATE/DROP in the tenant schema + +**Migrator with migration_role (PostgreSQL):** +- Configure `migration_role: :db_manager`, run Migrator +- Verify migrations ran with db_manager credentials (check schema ownership) +- Verify runtime pools (`:writing`) use app_user credentials + +**PendingMigrationError (SQLite):** +- Create tenant, add migration, verify error raised on pool creation in local env +- Verify no error in production-like env +- Verify no error during Migrator run + +**Schema cache (SQLite):** +- Generate cache, verify file exists at expected path +- Load cache on pool creation, verify schema cache is populated + +## Files + +``` +lib/ +├── apartment.rb # MODIFY — deregister_shard accepts composite pool key, +│ # extracts role from key format "tenant:role" +lib/apartment/ +├── current.rb # MODIFY — add :migrating attribute +├── config.rb # MODIFY — add migration_role, app_role, +│ # schema_cache_per_tenant, check_pending_migrations +├── errors.rb # MODIFY — add PendingMigrationError +├── pool_manager.rb # MODIFY — add remove_tenant(tenant), evict_by_role(role) +├── pool_reaper.rb # MODIFY — default_tenant guard uses prefix match +├── patches/ +│ └── connection_handling.rb # MODIFY — role-aware base config, pool key format, +│ # pending migration check, schema cache loading +├── migrator.rb # MODIFY — with_migration_role, Current.migrating, +│ # evict_migration_pools after run +├── schema_cache.rb # NEW — dump/dump_all/cache_path_for +├── adapters/ +│ ├── abstract_adapter.rb # MODIFY — base_config_override: keyword, +│ │ # grant_tenant_privileges dispatch, +│ │ # drop uses remove_tenant for all roles +│ ├── postgresql_schema_adapter.rb # MODIFY — resolve_connection_config base_config:, +│ │ # grant_privileges (6 SQL) +│ ├── postgresql_database_adapter.rb # MODIFY — resolve_connection_config base_config:, +│ │ # grant_privileges (5 SQL, see note) +│ ├── mysql2_adapter.rb # MODIFY — resolve_connection_config base_config:, +│ │ # grant_privileges (1 SQL) +│ ├── trilogy_adapter.rb # MODIFY — inherits from Mysql2Adapter (may need no change) +│ └── sqlite3_adapter.rb # MODIFY — resolve_connection_config base_config: +├── tasks/ +│ └── v4.rake # MODIFY — add apartment:schema:cache:dump + +spec/unit/ +├── connection_handling_role_spec.rb # NEW — role-aware pool resolution +├── migrator_role_spec.rb # NEW — with_migration_role, Current.migrating +├── rbac_grants_spec.rb # NEW — app_role grant logic per adapter +├── schema_cache_spec.rb # NEW — dump/load/path +├── pending_migration_spec.rb # NEW — check conditions and suppression + +spec/integration/v4/ +├── role_aware_connection_spec.rb # NEW — PG role-based pool resolution +├── rbac_grants_spec.rb # NEW — PG grant verification with real roles +├── migrator_rbac_spec.rb # NEW — Migrator with migration_role +``` + +## Out of Scope + +- Thor CLI commands (Phase 6) +- Automatic replica switching middleware (Rails provides this; Apartment doesn't need its own) +- Per-tenant connection configs / multi-shard support beyond what `connects_to` provides +- `PoolManager#evict_by_role` ~~(operational convenience; deferred unless needed)~~ — promoted to in-scope; used by `Migrator#evict_migration_pools` +- `ARTENANT=` single-tenant targeting (future) diff --git a/docs/designs/v4-phase5.2-rbac-integration-tests.md b/docs/designs/v4-phase5.2-rbac-integration-tests.md new file mode 100644 index 00000000..d6bcad71 --- /dev/null +++ b/docs/designs/v4-phase5.2-rbac-integration-tests.md @@ -0,0 +1,269 @@ +# Phase 5.2: RBAC Integration Tests + +## Overview + +Phase 5 shipped role-aware connections, RBAC privilege grants (`app_role`), `migration_role`, schema cache, and `PendingMigrationError`. The unit test coverage is solid, but no integration tests verify that these features work against real PostgreSQL roles or MySQL users. This phase fills that gap. + +**No production code changes.** This is purely additive test infrastructure and specs. + +## Motivation + +The Phase 5 design spec lists three integration test files that were deferred: +- `role_aware_connection_spec.rb` — PG role-based pool resolution +- `rbac_integration_spec.rb` — PG grant verification with real roles +- `migrator_rbac_spec.rb` — Migrator with `migration_role` + +Without these, the RBAC system is validated only at the SQL-string level (unit tests verify the right SQL is generated). Integration tests prove the grants actually enforce the expected privilege boundary against real database engines. + +## Design Decisions + +### Separate connections, not SET ROLE + +v4's architecture eliminates dynamic session-state mutation (`SET search_path`) in favor of pool-per-tenant with immutable connection configs. Using `SET ROLE` / `RESET ROLE` in tests would contradict this design: it mutates session state on a shared connection. + +Instead, RBAC integration tests use **real LOGIN roles with separate connections**: +- Grant verification tests use `establish_connection` with different PG/MySQL usernames (same database, different credentials). +- Role-aware connection routing tests wire real `connects_to` mappings so `connected_to(role: :db_manager)` resolves to a pool with `apt_test_db_manager` credentials — the same path apartment's `ConnectionHandling` intercepts in production. + +The cost is connection pool churn, but these are integration tests that already create/destroy tenants; an extra `establish_connection` per test group is negligible. + +### Test role naming + +PostgreSQL roles are cluster-wide (not database-scoped). Prefixed names (`apt_test_db_manager`, `apt_test_app_user`) avoid collisions with roles developers may have for other projects. + +### CI provisioning + local dev fallback + +Roles are created in two places: +1. **CI**: Explicit psql/mysql step in the GitHub Actions workflow. Fast, visible, runs once. +2. **Local dev**: Idempotent `before(:context, :rbac)` hook in `RbacHelper`. If role creation fails (e.g., local PG user lacks CREATEROLE), specs skip with a clear message rather than failing. + +## Test Roles + +### PostgreSQL + +| Role | Attributes | Purpose | +|------|-----------|---------| +| `apt_test_db_manager` | `LOGIN CREATEDB` | DDL operations: create schemas, run migrations, own tables | +| `apt_test_app_user` | `LOGIN` | DML only: SELECT, INSERT, UPDATE, DELETE | + +Relationship: `GRANT apt_test_app_user TO apt_test_db_manager` (so db_manager can set default privileges for app_user). `GRANT CREATE ON DATABASE ... TO apt_test_db_manager` (so db_manager can create schemas). + +### MySQL + +| User | Privileges | Purpose | +|------|-----------|---------| +| `apt_test_db_manager`@`%` | `ALL PRIVILEGES ON *.* WITH GRANT OPTION` | Full DDL/DML + can grant to app_user | +| `apt_test_app_user`@`%` | `SELECT, INSERT, UPDATE, DELETE ON apartment_%.*` | DML on test databases only | + +## CI Provisioning + +### PostgreSQL job + +Roles are cluster-wide, so the CI step connects to the `postgres` maintenance database (not the test database, which may not exist yet). Database-specific grants run in `RbacHelper.provision_roles!` after `ensure_test_database!` creates the test database (`apartment_v4_test`): `GRANT CREATE ON DATABASE`, `GRANT ALL ON SCHEMA public` (PG 15+ revoked public CREATE from PUBLIC), `GRANT ALL ON ALL TABLES/SEQUENCES IN SCHEMA public` (covers pre-existing `schema_migrations`), and `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES/SEQUENCES` (covers objects created after provisioning by the session user). + +Step in the `postgresql:` CI job (separate job per engine, no `if:` needed): + +```yaml +- name: Provision RBAC test roles + run: | + psql -h 127.0.0.1 -U postgres -d postgres --set ON_ERROR_STOP=on <<'SQL' + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'apt_test_db_manager') THEN + CREATE ROLE apt_test_db_manager LOGIN CREATEDB; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'apt_test_app_user') THEN + CREATE ROLE apt_test_app_user LOGIN; + END IF; + END $$; + GRANT apt_test_app_user TO apt_test_db_manager; + SQL +``` + +Idempotent via `IF NOT EXISTS`. `GRANT` statements are inherently idempotent in PG. Trust auth (`POSTGRES_HOST_AUTH_METHOD: trust` already in CI) means no passwords needed. + +### MySQL job + +CI MySQL uses `MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'`, so no password flag on the client. + +Step in the `mysql:` CI job: + +```yaml +- name: Provision MySQL RBAC test roles + shell: bash + run: | + set -euo pipefail + mysql -h 127.0.0.1 -u root <<'SQL' + CREATE USER IF NOT EXISTS 'apt_test_db_manager'@'%'; + CREATE USER IF NOT EXISTS 'apt_test_app_user'@'%'; + GRANT ALL PRIVILEGES ON *.* TO 'apt_test_db_manager'@'%' WITH GRANT OPTION; + GRANT SELECT, INSERT, UPDATE, DELETE ON `apartment\_%`.* TO 'apt_test_app_user'@'%'; + FLUSH PRIVILEGES; + SQL +``` + +## RbacHelper Module + +`spec/integration/v4/support/rbac_helper.rb` — shared infrastructure for all RBAC spec files. + +```ruby +module RbacHelper + ROLES = { + db_manager: 'apt_test_db_manager', + app_user: 'apt_test_app_user' + }.freeze +end +``` + +### `provision_roles!(connection)` + +Idempotent role creation. Engine-aware: PG uses `CREATE ROLE`, MySQL uses `CREATE USER IF NOT EXISTS`. For PG, also runs `GRANT CREATE ON DATABASE TO apt_test_db_manager` (deferred from CI provisioning because the test database may not exist yet at that point). Returns `true` on success, `false` on failure (triggers skip). Called in `before(:context, :rbac)` for local dev parity. + +### `connect_as(role_key)` + +For grant verification tests (Approach A). Calls `establish_connection` with the same database but a different username. Stashes the original config for restoration. + +### `restore_default_connection!` + +Restores the connection stashed by `connect_as`. + +### `setup_connects_to!(base_config)` + +For role-aware connection and Migrator tests (Approach B). Registers database configs for `:writing` and `:db_manager` roles with AR's `ConnectionHandler`, using the same database but different usernames. This wires real `connected_to(role:)` support so apartment's `ConnectionHandling` patch resolves the correct base config per role. + +### `teardown_rbac_connections!` + +Disconnects and removes non-primary pools created during tests. + +## Test Files + +### `role_aware_connection_spec.rb` (PostgreSQL only) + +Tests that `ConnectionHandling` resolves different base configs depending on the active `connected_to` role. + +**Setup**: `setup_connects_to!` wires `:writing` and `:db_manager` roles. Creates a tenant. + +**Examples**: +- Separate pools created per role for the same tenant (different pool objects, different `current_user`) +- Pool keys differ by role (`"tenant:writing"` vs `"tenant:db_manager"`) +- Tenant config inherits the role's base config (db_manager username propagates through `resolve_connection_config`) + +### `rbac_grants_spec.rb` (PostgreSQL only) + +Tests that `app_role` grants enforce the expected privilege boundary. + +**Setup**: Connect as `apt_test_db_manager`, configure `app_role: 'apt_test_app_user'`, create a tenant. This triggers `PostgresqlSchemaAdapter#grant_privileges`. + +**Examples**: + +As `app_user`: +- Can SELECT, INSERT, UPDATE, DELETE in the tenant schema +- Cannot CREATE TABLE in the tenant schema (`permission denied`) +- Cannot DROP SCHEMA (`permission denied`) + +ALTER DEFAULT PRIVILEGES: +- As `db_manager`: create a new table in the tenant schema after initial creation +- As `app_user`: verify DML works on the new table (proves default privileges fired) + +As `db_manager`: +- Can CREATE TABLE and DROP SCHEMA (retains full DDL privileges) + +### `migrator_rbac_spec.rb` (PostgreSQL only) + +Tests that the Migrator uses elevated credentials when `migration_role` is configured. + +**Setup**: `setup_connects_to!` wires roles. Configure `migration_role: :db_manager` and `app_role`. Create tenants. Place a real migration file in a temp directory. + +**Examples**: +- Migrations run as db_manager (verify table ownership via `pg_tables.tableowner`) +- `app_user` can DML on migrated tables (default privileges chain works end-to-end) +- Migration-role pools evicted after run (no `db_manager` keys remain in `pool_manager`) +- Parallel threads: each thread uses db_manager credentials (verify table ownership across all tenants with `threads: 2`) + +### `mysql_rbac_grants_spec.rb` (MySQL only) + +Mirrors the PG grant spec but simpler. MySQL grants on `db.*` cover future tables automatically (no `ALTER DEFAULT PRIVILEGES` equivalent needed). + +**Setup**: Connect as `apt_test_db_manager`, configure `app_role: 'apt_test_app_user'`, create a tenant. + +**Examples**: +- As `app_user`: can SELECT, INSERT, UPDATE, DELETE +- As `app_user`: cannot CREATE TABLE, cannot DROP DATABASE +- As `db_manager`: retains full DDL privileges + +## Tagging Strategy + +All specs tagged `:rbac` plus engine-specific tag (`:postgresql_only` or `:mysql_only`). + +### Role provisioning hook + +Uses `before(:context, :rbac)` with a module-level flag to provision once, rather than `before(:suite)` with `inclusion_filter`. The `before(:suite)` approach is fragile: it only fires when `--tag rbac` is passed explicitly, but `:rbac`-tagged examples also run when executing the full integration suite without that filter. + +```ruby +RSpec.configure do |config| + config.before(:context, :rbac) do + next if RbacHelper.provisioned? + + unless RbacHelper.provision_roles!(ActiveRecord::Base.connection) + skip 'RBAC test roles not available. See docs/designs/v4-phase5.2-rbac-integration-tests.md' + end + end +end +``` + +`RbacHelper.provisioned?` is a module-level boolean that prevents re-running provisioning for each describe block. On failure, `provision_roles!` returns false and all `:rbac` examples skip with a clear message. + +### ConnectionHandler swap interaction + +The existing `:integration` tag's `around(:each)` hook swaps `ActiveRecord::ConnectionHandler` per example and re-establishes the default connection. This discards any `connects_to` registrations made in `before(:context)`. + +RBAC specs that use `setup_connects_to!` (role-aware connection and Migrator tests) must call it inside `before(:each)`, after the handler swap has created the fresh handler. This means `setup_connects_to!` runs per-example — acceptable since it's just two `establish_connection` calls (`:writing` and `:db_manager`). + +Grant verification specs (`rbac_grants_spec.rb`, `mysql_rbac_grants_spec.rb`) use `connect_as` / `restore_default_connection!` which are also per-example and compatible with the handler swap. + +Skip message on failure: +``` +Skipped: RBAC test roles not available. + PostgreSQL: psql -U postgres -c "CREATE ROLE apt_test_db_manager LOGIN CREATEDB; CREATE ROLE apt_test_app_user LOGIN;" + MySQL: mysql -u root -c "CREATE USER 'apt_test_db_manager'@'%'; CREATE USER 'apt_test_app_user'@'%';" +``` + +## Running + +```bash +# All RBAC specs (PostgreSQL) +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql \ + rspec spec/integration/v4/ --tag rbac + +# Just grant verification (PostgreSQL) +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql \ + rspec spec/integration/v4/rbac_grants_spec.rb + +# MySQL grants +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 \ + rspec spec/integration/v4/mysql_rbac_grants_spec.rb +``` + +## Files + +``` +.github/workflows/ci.yml # MODIFY — add RBAC role provisioning steps + +spec/integration/v4/ + support/ + rbac_helper.rb # NEW — role provisioning, connect_as, setup_connects_to! + role_aware_connection_spec.rb # NEW — :rbac, :postgresql_only + rbac_grants_spec.rb # NEW — :rbac, :postgresql_only + migrator_rbac_spec.rb # NEW — :rbac, :postgresql_only + mysql_rbac_grants_spec.rb # NEW — :rbac, :mysql_only +``` + +## Out of Scope + +- Thor CLI commands (Phase 6) +- SQLite RBAC (SQLite has no role system) +- Per-tenant schema cache integration tests (unit coverage sufficient; Phase 5 spec listed these but the dump/load API is straightforward file IO) +- `PendingMigrationError` integration test (Phase 5 spec listed a SQLite test; deferred because the check is a single `needs_migration?` call gated by config/env/Current flags, all of which have unit coverage. If it breaks in integration, the symptom is obvious: dev server raises on first request.) +- `prevent_writes: true` propagation to tenant pools (this is an AR `connected_to` option handled by Rails' `ConnectionHandler`, not by apartment's `ConnectionHandling` patch; apartment passes the role through, AR enforces write protection) +- Stress testing RBAC under concurrent load (Phase 7) +- `PostgresqlDatabaseAdapter` RBAC grants (the adapter inherits no-op from `AbstractAdapter`; the design spec recommends the callable escape hatch for database-per-tenant RBAC) diff --git a/docs/designs/v4-phase6-cli-generator.md b/docs/designs/v4-phase6-cli-generator.md new file mode 100644 index 00000000..59adfcd0 --- /dev/null +++ b/docs/designs/v4-phase6-cli-generator.md @@ -0,0 +1,302 @@ +# Phase 6: CLI & Generator Design + +> **Parent spec:** [`apartment-v4.md`](apartment-v4.md) +> **Depends on:** Phase 4 (Railtie & Migrations) + +## Overview + +Phase 6 replaces the inline rake task logic with a Thor CLI as the primary interface, refactors rake tasks into thin backward-compatible wrappers, and rewrites the install generator template for v4 Config. + +Two sub-phases: +- **6.1** — Thor CLI + rake refactor +- **6.2** — Generator template + binstub + +## File Structure + +``` +lib/apartment/ +├── cli.rb # Entry point: registers subcommands, defines exit_on_failure? +├── cli/ +│ ├── tenants.rb # create, drop, list, current +│ ├── migrations.rb # migrate, rollback +│ ├── seeds.rb # seed +│ └── pool.rb # stats, evict + +lib/apartment/tasks/ +└── v4.rake # Thin wrappers delegating to CLI classes + +lib/generators/apartment/install/ +├── install_generator.rb # Creates initializer + binstub +└── templates/ + ├── apartment.rb # v4 Config initializer template + └── binstub # bin/apartment template + +spec/unit/ +├── cli_spec.rb # Subcommand registration, help output +├── cli/ +│ ├── tenants_spec.rb +│ ├── migrations_spec.rb +│ ├── seeds_spec.rb +│ └── pool_spec.rb +└── generator/ + └── install_generator_spec.rb +``` + +## CLI Architecture + +### Entry Point (`cli.rb`) + +`Apartment::CLI < Thor` registers four subcommands and serves as the target for `bin/apartment`. No command methods live here. + +```ruby +module Apartment + class CLI < Thor + def self.exit_on_failure? = true + + register CLI::Tenants, 'tenants', 'tenants COMMAND', 'Tenant lifecycle commands' + register CLI::Migrations, 'migrations', 'migrations COMMAND', 'Migration commands' + register CLI::Seeds, 'seeds', 'seeds COMMAND', 'Seed commands' + register CLI::Pool, 'pool', 'pool COMMAND', 'Connection pool commands' + end +end +``` + +Invocation: `apartment tenants create`, `apartment migrations migrate`, `apartment pool stats`. + +### Binstub (`bin/apartment`) + +Generated by `rails generate apartment:install`: + +```ruby +#!/usr/bin/env ruby +require_relative '../config/environment' +require 'apartment/cli' +Apartment::CLI.start(ARGV) +``` + +Uses `require_relative` (same convention as `bin/rails`). Rails boots before Thor dispatches. + +### Tenants (`cli/tenants.rb`) + +`Apartment::CLI::Tenants < Thor` + +**`create [TENANT]`** +- With argument: `Apartment::Tenant.create(tenant)`. Rescues `TenantExists` with skip message. +- Without argument: iterates `tenants_provider.call`, creates all, collects errors, reports summary. Exits non-zero if any failures. +- `method_option :quiet, type: :boolean` — suppress per-tenant output. + +**Environment variable aliases:** `APARTMENT_QUIET=1` serves as a fallback for `--quiet` on commands that define it. `APARTMENT_FORCE=1` applies to any subcommand that defines `--force` (`drop`, `pool evict`, and any future destructive commands). Thor options take precedence when both are set. + +**`drop TENANT`** +- Required argument. No "drop all" (too dangerous). +- `method_option :force, type: :boolean` — skip confirmation prompt. +- Without `--force`: prompts via `yes?` before proceeding. + +**`list`** +- Prints `tenants_provider.call`, one per line. + +**`current`** +- Prints `Apartment::Current.tenant || Apartment.config.default_tenant || 'none'`. + +### Migrations (`cli/migrations.rb`) + +`Apartment::CLI::Migrations < Thor` + +**`migrate [TENANT]`** +- Without argument: delegates to `Apartment::Migrator.new(threads:, version:).run`. Prints `result.summary`. Exits non-zero on failure. Triggers schema dump if `ActiveRecord.dump_schema_after_migration`. +- With argument: delegates to `Apartment::Migrator.new(version:).migrate_one(tenant)`. This reuses the same code path as the all-tenants run (`migrate_tenant`), preserving RBAC role wrapping, advisory lock disabling, `Current.migrating` flag, and instrumentation. The CLI must not reimplement migration logic outside the Migrator. +- `method_option :version, type: :numeric` — target migration version. Falls back to `ENV['VERSION']` for backward compat with `rake apartment:migrate VERSION=X`. +- `method_option :threads, type: :numeric` — overrides `Apartment.config.parallel_migration_threads`. + +**New Migrator API: `migrate_one(tenant)`** + +Public method on `Apartment::Migrator` that migrates a single named tenant. Internally calls the existing private `migrate_tenant` (which handles `with_migration_role`, `with_advisory_locks_disabled`, `Current.migrating`, and instrumentation). Returns a single `Result`. Runs `evict_migration_pools` in an ensure block. This avoids CLI/rake divergence from the Migrator's semantics. + +**`rollback [TENANT]`** +- Without argument: iterates all tenants sequentially, rolls back each. Collects errors, reports summary. +- With argument: rolls back just that tenant. +- `method_option :step, type: :numeric, default: 1` — steps to rollback. + +Rollback iterates directly (no Migrator). Parallel rollback is not a real need; rollback is inherently sequential and cautious. + +### Seeds (`cli/seeds.rb`) + +`Apartment::CLI::Seeds < Thor` + +**`seed [TENANT]`** +- Without argument: iterates all tenants, calls `Apartment::Tenant.seed(tenant)` for each. Collects errors, reports summary. +- With argument: seeds just that tenant. + +### Pool (`cli/pool.rb`) + +`Apartment::CLI::Pool < Thor` + +**`stats`** +- Calls `Apartment.pool_manager.stats`, prints summary (total pools, total connections, idle counts). +- `method_option :verbose, type: :boolean` — per-tenant breakdown (tenant name, pool size, seconds idle). +- If `pool_manager` is nil, prints a message and exits. + +**`evict`** +- Triggers immediate eviction cycle via `Apartment.pool_reaper.run_cycle`. +- Prints how many pools were evicted. +- `method_option :force, type: :boolean` — skip confirmation. + +**New PoolReaper API: `run_cycle`** + +Public method on `Apartment::PoolReaper` that performs one synchronous eviction pass (the same logic as the private `#reap` method called by the background timer). Returns the count of evicted pools. This gives the CLI a named contract instead of reaching into private APIs. + +## Rake Refactor + +Rake tasks become thin wrappers delegating to CLI classes: + +```ruby +namespace :apartment do + task :create, [:tenant] => :environment do |_t, args| + if args[:tenant] + Apartment::CLI::Tenants.new.invoke(:create, [args[:tenant]]) + else + Apartment::CLI::Tenants.new.invoke(:create) + end + end + + task :drop, [:tenant] => :environment do |_t, args| + Apartment::CLI::Tenants.new.invoke(:drop, [args[:tenant]]) + end + + task migrate: :environment do + Apartment::CLI::Migrations.new.invoke(:migrate) + end + + task :rollback, [:step] => :environment do |_t, args| + Apartment::CLI::Migrations.new.invoke(:rollback, [], step: (args[:step] || 1).to_i) + end + + task seed: :environment do + Apartment::CLI::Seeds.new.invoke(:seed) + end +end +``` + +The `schema:cache:dump` task stays as-is (delegates to `SchemaCache`). It is not duplicated as a Thor command because schema cache operations are a rake-native concern (tied to `db:schema:dump` lifecycle), not an operator CLI action. + +Env var backward compat: `VERSION=` is read by the Thor `migrate` command as a fallback when `--version` isn't passed. + +## Generator Template + +### Initializer (`templates/apartment.rb`) + +Minimal scaffold with only the two required options uncommented. Everything else is commented examples grouped by category. No v3 references. + +```ruby +Apartment.configure do |config| + # == Required =========================================================== + config.tenant_strategy = :schema + config.tenants_provider = -> { raise "TODO: replace with e.g. Account.pluck(:subdomain)" } + + # == Tenant Defaults ===================================================== + # config.default_tenant = 'public' + # config.excluded_models = %w[Account] + + # == Connection Pool ===================================================== + # config.tenant_pool_size = 5 + # config.pool_idle_timeout = 300 + # config.max_total_connections = nil + + # == Elevator (Request Tenant Detection) ================================= + # config.elevator = :subdomain + # config.elevator_options = {} + + # == Migrations ========================================================== + # config.parallel_migration_threads = 0 + # config.schema_load_strategy = nil # :schema_rb or :sql + # config.seed_after_create = false + # config.check_pending_migrations = true + + # == RBAC & Roles ========================================================= + # config.migration_role = nil # e.g. :db_manager (Phase 5 role-aware connections) + # config.app_role = nil # e.g. 'app_role' or -> { "app_#{Rails.env}" } + # config.environmentify_strategy = nil # nil, :prepend, :append, or a callable + + # == PostgreSQL =========================================================== + # config.configure_postgres do |pg| + # pg.persistent_schemas = %w[shared extensions] + # end + + # == MySQL ================================================================ + # config.configure_mysql do |my| + # end +end +``` + +Key changes from v3: +- No `require 'apartment/elevators/...'` (Railtie resolves elevator from symbol) +- No `Rails.application.config.middleware.use` (Railtie auto-wires) +- No `tenant_names`, `use_schemas`, `use_sql`, `prepend_environment`, `pg_excluded_names` +- `tenants_provider` raises to force deliberate configuration + +### Install Generator (`install_generator.rb`) + +Two actions: +1. `template 'apartment.rb', 'config/initializers/apartment.rb'` +2. `template 'binstub', 'bin/apartment'` + `chmod 'bin/apartment', 0o755` + +## Test Strategy + +Unit tests only (no database). Consistent with `spec/unit/` pattern. + +### CLI Command Specs (`spec/unit/cli/`) + +Invoke Thor commands programmatically. Stub `Apartment::Tenant`, `Apartment::Migrator`, `Apartment.pool_manager`, `Apartment.config`. Verify: +- Correct delegation (e.g., `create` calls `Tenant.create` with right args) +- Error collection and summary output +- Exit codes (non-zero on failure) +- Option handling (`--force`, `--threads`, `--verbose`) +- Confirmation prompts (stub `yes?`) + +### CLI Registration Spec (`spec/unit/cli_spec.rb`) + +Verifies subcommand registration on `Apartment::CLI`. Thor's internal API for inspecting registered subcommands varies across versions; the spec should assert via `Apartment::CLI.commands` or by invoking `apartment help` and matching output, rather than relying on a specific internal accessor. The implementation should pick whichever approach works with Thor >= 1.3.0 and document it. + +### Generator Spec (`spec/unit/generator/install_generator_spec.rb`) + +Uses Rails generator test helpers. Asserts: +- `config/initializers/apartment.rb` created with v4 content (contains `tenant_strategy`, `tenants_provider`; no v3 references like `tenant_names`, `use_schemas`) +- `bin/apartment` created and executable +- Key strings present/absent in template output + +### Rake Specs + +Verify each rake task invokes the corresponding CLI method. + +## Sub-Phase Breakdown + +### Phase 6.1: Thor CLI + Rake Refactor + +**Branch:** `man/v4-phase6-cli-generator` + +Deliverables: +- `lib/apartment/cli.rb` +- `lib/apartment/cli/tenants.rb` +- `lib/apartment/cli/migrations.rb` +- `lib/apartment/cli/seeds.rb` +- `lib/apartment/cli/pool.rb` +- `lib/apartment/migrator.rb` — add public `migrate_one(tenant)` method +- `lib/apartment/pool_reaper.rb` — add public `run_cycle` method +- Refactored `lib/apartment/tasks/v4.rake` +- `spec/unit/cli_spec.rb` +- `spec/unit/cli/tenants_spec.rb` +- `spec/unit/cli/migrations_spec.rb` +- `spec/unit/cli/seeds_spec.rb` +- `spec/unit/cli/pool_spec.rb` +- `spec/unit/migrator_spec.rb` — tests for `migrate_one` +- `spec/unit/pool_reaper_spec.rb` — tests for `run_cycle` + +### Phase 6.2: Generator + Binstub + +**Branch:** same (`man/v4-phase6-cli-generator`), separate PR + +Deliverables: +- Updated `lib/generators/apartment/install/install_generator.rb` +- Rewritten `lib/generators/apartment/install/templates/apartment.rb` +- New `lib/generators/apartment/install/templates/binstub` +- `spec/unit/generator/install_generator_spec.rb` diff --git a/docs/designs/v4-phase7-integration-tests.md b/docs/designs/v4-phase7-integration-tests.md new file mode 100644 index 00000000..29149f0a --- /dev/null +++ b/docs/designs/v4-phase7-integration-tests.md @@ -0,0 +1,137 @@ +# Phase 7: Integration & Stress Tests + +**Branch:** `man/v4-phase7-integration-tests` +**Depends on:** Phases 1-6 (all merged to `development`) +**Approach:** Single PR, all deliverables + +## Motivation + +Phases 1-6 built the v4 runtime: pool-per-tenant, fiber-safe CurrentAttributes, adapters, elevators, RBAC, CLI. Existing integration specs (`spec/integration/v4/`) cover tenant switching, lifecycle, excluded models, edge cases, concurrency, pool scaling, reaper eviction, LRU, request lifecycle, and RBAC. Phase 7 fills the remaining gaps identified during review. + +## Gap Analysis + +| Design spec deliverable | Current state | Action | +|---|---|---| +| Connection pool isolation | `stress_spec.rb:89-106` proves idempotent pool fetch. Data isolation tested via counts. | Harden: add explicit per-thread `Tenant.current` assertion + cross-tenant checkout isolation test | +| Thread safety | `stress_spec.rb:61-87` does 10x50 concurrent switches. `current_spec.rb:27-37` unit-tests thread isolation. | Harden: add barrier-synchronized `Tenant.current` checks inside threads | +| Fiber safety | Zero fiber specs anywhere. `spec/CLAUDE.md` claims coverage via CurrentAttributes but nothing proves it. | **New file:** `fiber_safety_spec.rb` | +| Pool eviction | `stress_spec.rb:189-221` (idle timeout) + `coverage_gaps_spec.rb:109-175` (LRU). | No action needed | +| Memory stability | No leak detection. 50-tenant scaling test exists but doesn't measure pool count invariants over sustained load. | **New file:** `memory_stability_spec.rb` | +| CLI smoke test | `spec/unit/cli_spec.rb` tests help output only. Phase 6 PR review flagged missing integration test. | **New file:** `cli_integration_spec.rb` | +| Appraisals + CI | Fully configured: Ruby 3.3/3.4/4.0 x Rails 7.2/8.0/8.1 x PG 16+18/MySQL 8.4/SQLite3. | No action needed | +| Request lifecycle | Complete in `request_lifecycle_spec.rb` (elevator->switch->response->cleanup). | No action needed | + +## File Structure + +No new directories. All new files live in `spec/integration/v4/` (flat, consistent with existing layout and activerecord-tenanted's pattern). + +``` +spec/integration/v4/ + fiber_safety_spec.rb # NEW + memory_stability_spec.rb # NEW + cli_integration_spec.rb # NEW + stress_spec.rb # MODIFIED (2 new `it` blocks) +``` + +## Spec Designs + +### 1. `fiber_safety_spec.rb` + +**Proves:** `Apartment::Current` (backed by `ActiveSupport::CurrentAttributes` / `IsolatedExecutionState`) isolates tenant state across fibers. + +**Tests:** + +- **Basic fiber isolation**: Parent fiber sets tenant A, spawns child fiber that sets tenant B, parent still reads tenant A after child completes. Mirror of `current_spec.rb:27-37` thread test but with `Fiber.new`. +- **Nested fiber switching**: Fiber does `Tenant.switch('x') { Fiber.yield }`, resumes, tenant still correct after yield/resume cycle. +- **Switch block + fiber interaction**: Outer `switch('a')` block, inner fiber does `switch('b')`, outer block still in tenant A after fiber returns. +- **Fiber scheduler integration** (conditional, `skip` unless `Fiber.respond_to?(:scheduler)` and Ruby >= 3.1): Use `Fiber.schedule` under a basic scheduler to prove async fiber dispatch doesn't leak tenant state. + +- **`load_async` fiber integration** (conditional, `skip` unless `ActiveRecord::Relation.method_defined?(:load_async)`): Switch to tenant A, fire `Widget.where(name: 'x').load_async` inside a fiber, verify the query resolves against tenant A's pool. Bridges the fiber safety story to the Rails feature called out in `apartment-v4.md`. + +**Engine scope:** All engines (SQLite, PG, MySQL). Fiber isolation is engine-agnostic; it's a Ruby/Rails runtime concern. + +**Estimated size:** ~100-120 lines. + +### 2. `memory_stability_spec.rb` + +**Proves:** Pool count stays bounded under sustained switching. No connection/pool leaks when `max_total_connections` is configured and the reaper is active. + +**Tests:** + +- **Pool count stays bounded**: Configure `max_total_connections = 5`, create 20 tenants, switch through all 20 in a loop (3 full cycles). After each cycle, invoke `pool_reaper.run_cycle` (public API; `coverage_gaps_spec.rb:166` uses `send(:reap)` which is stale — that spec should be updated as a drive-by), then assert `pool_manager.stats[:total_pools] <= max_total_connections`. +- **Repeated create/drop doesn't leak pools**: Create tenant, switch into it, drop it — repeat 20 times. Assert pool count at end equals pool count at start (plus/minus 1 for default pool). Proves drop path cleans up pool entries. +- **Sustained switching without pool growth**: Configure generous `max_total_connections` (no eviction pressure), create 5 tenants, do 200 round-robin switches. Assert final pool count == 5. No phantom pools from race conditions or double-registration. + +**Engine scope:** PG and MySQL for the bounded-pool and create/drop tests. SQLite gets a lightweight smoke test: bounded pool count under N tenants (single-writer lock makes concurrent access meaningless, but pool accounting still applies). + +**Design decision — no RSS/ObjectSpace measurement:** Flaky across Ruby versions and GC timing. Pool count invariant is the meaningful signal; if pools don't leak, connections don't leak. + +**Estimated size:** ~100-120 lines. + +### 3. `cli_integration_spec.rb` + +**Proves:** Thor CLI commands perform real tenant operations against a live database. + +**Tests:** + +- **`tenants list`**: Create 3 tenants via adapter, invoke `Apartment::CLI::Tenants.new.invoke(:list)`, capture stdout. Assert all 3 names appear. +- **`tenants create`**: Invoke `Apartment::CLI::Tenants.new.invoke(:create, ['cli_tenant'])`, verify tenant exists by switching into it and executing a query. +- **`tenants drop`**: Create tenant via adapter, invoke `Apartment::CLI::Tenants.new.invoke(:drop, ['cli_tenant'], force: true)` (or set `ENV['APARTMENT_FORCE'] = '1'`) to bypass the confirmation prompt in non-interactive test runs. Verify adapter raises `TenantNotFound` on subsequent drop attempt. +- **`pool stats`**: Access a tenant to populate a pool, invoke `Apartment::CLI::Pool.new.invoke(:stats)`, capture stdout and assert output includes `total_pools` and tenant name. + +**Not tested here:** `migrations run` / `seeds load` — covered by `migrator_integration_spec.rb`. CLI is a thin Thor wrapper; proving CRUD + stats wiring is sufficient. + +**Engine scope:** All engines. + +**Estimated size:** ~80-100 lines. + +### 4. Hardening `stress_spec.rb` + +**What changes:** Additive only: two new `it` blocks in the existing `concurrent switching` context. Existing examples are not changed. + +**Test A — Explicit tenant identity per thread:** +5 threads, each assigned a specific tenant. `CyclicBarrier` synchronizes entry. Inside switch block, assert both `Apartment::Tenant.current` and `Apartment::Current.tenant` equal the assigned tenant (locks the alias relationship). Results collected in `Concurrent::Map`, assertions on main thread. + +**Why:** Existing test proves correct data distribution (500 writes across tenants) but doesn't prove each thread sees the correct tenant identity. A bug where `Tenant.current` returns stale state but pool resolution works correctly would pass the existing test but fail this one. + +**Test B — Cross-tenant connection checkout isolation:** +2 threads with dedicated tenants (`tenant_a`, `tenant_b`). Barrier ensures both are inside switch blocks simultaneously. Each thread inserts a row tagged with its thread index into the shared `widgets` table (which exists per-tenant as a separate schema/database/file), then reads back `Widget.pluck(:name)`. Assert: thread A's read returns only `['thread_a']`, thread B's read returns only `['thread_b']`. Per-engine isolation mechanism (PG schema, MySQL database, SQLite file) means "only its own rows" is enforced by the adapter's tenant boundary, not by a `WHERE` clause. Proves no connection was checked out from the wrong pool mid-switch. + +**Why:** Existing concurrent test uses `tenants.sample` (random selection), which proves no errors and correct totals but can't assert per-thread isolation. Dedicated tenants with barrier makes the isolation assertion precise. + +**Estimated size:** ~60-70 lines added. + +## Implementation Order + +All 5 items are independent. Recommended order for implementation (not sub-phases; all in one PR): + +1. `fiber_safety_spec.rb` — smallest, most self-contained, proves a v4 value proposition +2. `memory_stability_spec.rb` — depends on understanding reaper internals (already proven in existing specs) +3. `stress_spec.rb` hardening — additive to existing file, low risk +4. `cli_integration_spec.rb` — depends on understanding Phase 6 Thor structure +5. Run full suite across all engines, verify CI green + +## Test Execution + +```bash +# New specs individually +bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/fiber_safety_spec.rb +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/memory_stability_spec.rb +bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/cli_integration_spec.rb + +# Full integration suite (all engines) +bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ +``` + +## Success Criteria + +- All new specs pass on PG, MySQL, and SQLite (where applicable) +- Existing examples unchanged and still passing +- CI matrix green (no new failures) +- Optional manual gate: `COVERAGE=1` run shows no coverage regression (not part of CI; run locally before merge) + +## Drive-by Fix + +`coverage_gaps_spec.rb:166` uses `Apartment.pool_reaper.send(:reap)` — update to `Apartment.pool_reaper.run_cycle` (public API) while we're in the file neighborhood. diff --git a/docs/designs/v4-phase7.1-excluded-models-pin-tenant.md b/docs/designs/v4-phase7.1-excluded-models-pin-tenant.md new file mode 100644 index 00000000..0e1c42db --- /dev/null +++ b/docs/designs/v4-phase7.1-excluded-models-pin-tenant.md @@ -0,0 +1,255 @@ +# Phase 7.1: Excluded Models Fix + Apartment::Model Concern + +## Overview + +Phase 7.1 fixes excluded model isolation for database-per-tenant strategies (MySQL, SQLite, PG database mode) and introduces `Apartment::Model` with `pin_tenant` as the primary API for declaring models that bypass tenant switching. `config.excluded_models` is deprecated with a compatibility shim. + +**Primary goal:** Excluded models work correctly across all tenant strategies, not just PG schemas. + +**Secondary goals:** +- Model-level `pin_tenant` DSL aligned with Rails conventions +- `ConnectionHandling` respects pinned model connections +- Hardened `process_pinned_models` with validation +- Comprehensive test matrix (all strategies, edge cases, multi-db coexistence) + +## Context & Motivation + +### The Bug: ConnectionHandling Overrides Excluded Model Connections + +`ConnectionHandling#connection_pool` (Phase 2.3) intercepts `ActiveRecord::Base.connection_pool` and returns a tenant-specific pool when `Current.tenant` is set. It never checks whether the calling model class has its own pinned connection established by `process_excluded_models`. + +For PG schema strategy, this is masked: the excluded model's table lives in `public` schema, which is accessible from any `search_path`. For database-per-tenant strategies (MySQL, SQLite, PG database), the override points to a different database entirely — the excluded model's table doesn't exist there. + +Three specs in `spec/integration/v4/excluded_models_spec.rb` are `pending` for non-PG engines, documenting this gap. + +### Why `Apartment::Model` Instead of Just Fixing `ConnectionHandling` + +`config.excluded_models` is a centralized string list that requires `constantize` at boot time (fragile with Zeitwerk), scatters the "this model is global" decision away from the model, and only supports pinning to the default tenant. + +Rails convention is model-level declaration: `belongs_to`, `has_many`, `connects_to`, `acts_as_paranoid`. `pin_tenant` follows the imperative verb pattern established by the ecosystem and places the declaration where it belongs — in the model file. + +Prior art: `lib/apartment/concerns/model.rb` from PR #327 (4.0.0.alpha1, `man/spec-restart` branch, SHA 41776920). + +**Correction to Phase 2.3 design:** `docs/designs/phase-2.3-connection-handling.md` suggested `super` would find the right pool for `connects_to` models. Phase 7.1's analysis shows this is not the case — the prepend intercepts before `super` can route via `connection_specification_name`. The `connects_to` coexistence issue is pre-existing; Phase 7.1 addresses the Apartment-specific case (pinned models) via explicit registry. + +## Design + +### 1. `Apartment::Model` Concern + +**File:** `lib/apartment/concerns/model.rb` + +```ruby +module Apartment + module Model + extend ActiveSupport::Concern + + class_methods do + # Declare this model as pinned to the default tenant. + # Pinned models bypass tenant switching in ConnectionHandling — + # their connection always targets the default tenant's database/schema. + # + # Must be called after `include Apartment::Model`. Safe to call + # before or after Apartment.activate! (deferred processing handles both). + # + # Idempotent: no-op if this class (or a parent) is already pinned. + # Uses connection_specification_name as the ground truth — class instance + # variables don't inherit, but AR connection ownership does. + def pin_tenant + return if apartment_pinned? + + Apartment.register_pinned_model(self) + @apartment_pinned = true + + # If Apartment is already activated, process immediately (Zeitwerk autoload path). + # Otherwise, activate! will process all registered models. + Apartment.process_pinned_model(self) if Apartment.activated? + end + + def apartment_pinned? + # Check class ivar first (set by pin_tenant on this exact class), + # then walk superclass chain for inherited pins. + # Does NOT use connection_specification_name — that heuristic is + # unsafe with ApplicationRecord (see ConnectionHandling section). + return true if @apartment_pinned == true + return false unless superclass.respond_to?(:apartment_pinned?) + + superclass.apartment_pinned? + end + end + end +end +``` + +**Usage:** + +```ruby +class GlobalSetting < ApplicationRecord + include Apartment::Model + pin_tenant +end +``` + +**STI / subclass semantics:** Pinning inherits via Rails' `connection_specification_name`. If `GlobalSetting` is pinned and `AdminSetting < GlobalSetting`, `AdminSetting` shares the pinned connection because `connection_specification_name` is inherited through the class hierarchy. Calling `pin_tenant` on a subclass whose parent is already pinned is a no-op — `apartment_pinned?` detects the inherited spec name divergence and returns `true`. + +**Third-party models:** Models from gems cannot `include Apartment::Model` without monkeypatching. The deprecated `config.excluded_models` path handles this case (see section 4). + +### 2. `ConnectionHandling` Fix + +**File:** `lib/apartment/patches/connection_handling.rb` + +Add an early return after the three existing guards (`tenant.nil?`, `default_tenant`, `pool_manager`), before tenant pool resolution: + +```ruby +# Skip tenant override for Apartment pinned models. +# Uses explicit registry (not connection_specification_name heuristic) +# because ApplicationRecord subclasses have a different spec name than +# ActiveRecord::Base while sharing the same pool — a spec-name comparison +# would incorrectly bypass tenant routing for ALL models in standard Rails apps. +return super if self != ActiveRecord::Base && Apartment.pinned_model?(self) +``` + +**Why explicit registry, not `connection_specification_name` heuristic:** + +The original design proposed comparing `connection_specification_name` against `ActiveRecord::Base.connection_specification_name`. This is unsafe in standard Rails apps: + +- `ActiveRecord::Base.connection_specification_name` → `"ActiveRecord::Base"` +- `ApplicationRecord.connection_specification_name` → `"ApplicationRecord"` (abstract class) +- `User.connection_specification_name` → `"ApplicationRecord"` (inherited) + +These differ even though they resolve to the same pool via Rails' hierarchical lookup. A spec-name comparison would cause every `ApplicationRecord` subclass to bypass tenant routing — a severe regression. + +The explicit registry avoids this entirely: only models registered via `pin_tenant` or `config.excluded_models` are bypassed. + +**`Apartment.pinned_model?` implementation:** + +```ruby +def self.pinned_model?(klass) + klass.ancestors.any? { |a| a.is_a?(Class) && pinned_models.include?(a) } +end +``` + +Walks the class hierarchy (not the full ancestor chain of modules) against the `pinned_models` set. Handles STI: if `GlobalSetting` is pinned, `AdminSetting < GlobalSetting` is caught because `GlobalSetting` appears in `AdminSetting.ancestors`. Short-circuits on first match. Ancestor chains are typically 3-5 classes; `Concurrent::Set#include?` is O(1). + +**Placement:** After `return super unless Apartment.pool_manager` (existing line 18), before `role = ActiveRecord::Base.current_role` (existing line 20). + +**`connects_to` models (known limitation):** Models using Rails' `connects_to` for multi-database setups are NOT covered by this guard. The current `ConnectionHandling` patch (pre-Phase 7.1) already misroutes them during tenant switches — this is a pre-existing issue, not introduced here. Phase 7.1's `connects_to` coexistence test verifies we don't make it worse. A general fix (e.g., pool identity comparison via `super`) is deferred to a future phase if needed. + +**Test requirement:** Integration tests MUST use `ApplicationRecord` (or an abstract base class) for tenant-participating models, not just `Class.new(ActiveRecord::Base)`, to validate the guard works in real Rails app topology. + +### 3. `process_pinned_models` (replaces `process_excluded_models`) + +**File:** `lib/apartment/adapters/abstract_adapter.rb` + +Rename `process_excluded_models` to `process_pinned_models`. Changes: + +1. **Source:** Iterates `Apartment.pinned_models` (populated by `pin_tenant` and the `excluded_models` shim) instead of `config.excluded_models` +2. **Table existence validation:** Deferred. Boot/migrate ordering makes this fragile (`activate!` may run before migrations create the table). Not implemented in Phase 7.1; current behavior (no validation) matches v3. Add in a follow-up if real apps hit confusing errors +3. **Schema strategy table_name rewrite:** Unchanged — prefix with `default_tenant.` for schema strategy +4. **Idempotent:** Skip models where `@apartment_connection_established` is already set (class-level ivar, not `connection_specification_name` comparison — same `ApplicationRecord` baseline issue applies here) + +`process_excluded_models` becomes an alias that emits a deprecation warning and delegates to `process_pinned_models`. + +### 4. `config.excluded_models` Deprecation Shim + +**File:** `lib/apartment/config.rb` + +`excluded_models=` setter emits a deprecation warning: + +``` +[Apartment] DEPRECATION: config.excluded_models is deprecated and will be removed in v5. +Use `include Apartment::Model` and `pin_tenant` in each model instead. +For third-party gem models, use config.excluded_models as a transitional escape hatch. +``` + +**Processing:** During `Tenant.init`, before calling `process_pinned_models`, the `resolve_excluded_models_shim` iterates `config.excluded_models`, resolves each string to a constant via `constantize`, and calls `Apartment.register_pinned_model(klass)`. Skips models already in `pinned_models` (duplicate protection). Raises `ConfigurationError` on unresolvable constant names. + +### 5. `Apartment` Module Additions + +**File:** `lib/apartment.rb` + +```ruby +module Apartment + # Registry of models that declared pin_tenant. + # Populated at class load time, processed during activate!. + # Uses Concurrent::Set for thread safety (Zeitwerk autoload in threaded servers). + # concurrent-ruby is already a Rails dependency via ActiveSupport. + def self.pinned_models + @pinned_models ||= Concurrent::Set.new + end + + def self.register_pinned_model(klass) + pinned_models.add(klass) + end + + # Check if a class (or any of its ancestors) is a pinned model. + # Used by ConnectionHandling to skip tenant pool routing. + # Walks the class hierarchy (short-circuits on first match). + def self.pinned_model?(klass) + klass.ancestors.any? { |a| a.is_a?(Class) && pinned_models.include?(a) } + end + + def self.activated? + @activated == true + end + + def self.process_pinned_model(klass) + adapter&.process_pinned_model(klass) + end +end +``` + +`activate!` sets `@activated = true` after calling `process_pinned_models`. + +**Reset:** `clear_config` must also reset `@pinned_models = nil` and `@activated = false` to prevent cross-test leakage. + +### 6. Test Matrix + +**Updated `excluded_models_spec.rb`** — remove `pending` guards, all specs run on all engines: + +| Spec | PG schema | PG database | MySQL | SQLite | +|------|-----------|-------------|-------|--------| +| `pin_tenant` establishes dedicated connection | pass | pass | pass | pass | +| `pin_tenant` is idempotent | pass | pass | pass | pass | +| Pinned model queries target default DB | pass (existing) | pass (new) | pass (new) | pass (new) | +| Pinned model data persists across switches | pass (existing) | pass (new) | pass (new) | pass (new) | +| Pinned model writes inside tenant block land in default | pass (existing) | pass (new) | pass (new) | pass (new) | + +**New specs:** + +| Spec | Strategy | Notes | +|------|----------|-------| +| `has_many :through` pinned model | PG schema | Join works via search_path | +| `has_many :through` pinned model | MySQL/SQLite | Expect clear error (cross-DB join) or skip with rationale | +| Concurrent pinned model access (2 threads, different tenants) | All | Both read/write default DB | +| `connects_to` multi-db model coexistence | All | Verify no worse than pre-7.1 (known pre-existing limitation) | +| ApplicationRecord tenant model topology | All | Tenant routing works when models inherit from abstract ApplicationRecord | +| STI subclass of pinned model | All | Inherits pinned behavior | +| Pinned model declared after `activate!` | All | Zeitwerk autoload path | +| Duplicate pin (config + concern) | All | Warning emitted, no error | +| `config.excluded_models` shim | All | Deprecation warning, functional | +| Table existence validation failure | All | Actionable `ConfigurationError` | + +**Rails version coverage:** Appraisal matrix (7.2 / 8.0 / 8.1) covers `connection_specification_name` stability. + +## Files Changed + +| File | Change | +|------|--------| +| `lib/apartment/concerns/model.rb` | **New.** `Apartment::Model` concern with `pin_tenant` | +| `lib/apartment/patches/connection_handling.rb` | Early return for pinned/non-Base-spec models | +| `lib/apartment/adapters/abstract_adapter.rb` | `process_pinned_models` (rename + hardening), `process_pinned_model` (single model), deprecation alias | +| `lib/apartment.rb` | `pinned_models` registry, `register_pinned_model`, `pinned_model?`, `activated?`, `process_pinned_model` | +| `lib/apartment/config.rb` | Deprecation warning on `excluded_models=` | +| `lib/apartment/tenant.rb` | `init` calls `resolve_excluded_models_shim` then `process_pinned_models` | +| `spec/integration/v4/excluded_models_spec.rb` | Remove pending guards, add `pin_tenant` usage, expand matrix | +| `spec/unit/concerns/model_spec.rb` | **New.** Unit tests for `Apartment::Model` concern | +| `spec/unit/adapters/abstract_adapter_spec.rb` | Update `process_excluded_models` tests to cover `process_pinned_models` | + +## Out of Scope (Phase 7.2 or Phase 8) + +- **MySQL Migrator RBAC tests:** Port `migrator_rbac_spec.rb` to MySQL. Infrastructure exists (`RbacHelper.provision_mysql_roles!`, `setup_connects_to!`). ~3 new examples. +- **CI meta-confidence guard:** Replace `< 1` threshold with meaningful minimums (PG ~15, MySQL ~8) after this phase's specs stabilize the example counts. +- **`pin_tenant(:tenant_name)` — pin to non-default tenant:** API slot reserved but not implemented. Current `pin_tenant` always pins to default. +- **`ConfigurationMap` / runtime tenant config registry:** Prior art in PR #327. Evaluate for Phase 8 if dynamic tenant configs beyond `tenants_provider` are needed. +- **`Apartment::Logger` / tagged logging:** DX polish from PR #327. Phase 8 candidate. +- **Upgrade guide (`docs/4.0-Upgrade.md`):** Port useful sections from PR #327 into Phase 8 docs. diff --git a/docs/designs/v4-phase8-documentation.md b/docs/designs/v4-phase8-documentation.md new file mode 100644 index 00000000..8de737bc --- /dev/null +++ b/docs/designs/v4-phase8-documentation.md @@ -0,0 +1,357 @@ +# Phase 8: Documentation & Upgrade Guide + +## Goal + +Make v4 shippable by rewriting user-facing documentation to reflect the v4 API, providing a self-sufficient upgrade guide for external users, and updating maintainer docs for dual-release support (v4 + v3 maintenance). + +## Deliverables + +### 1. `docs/history/` — Legacy Documentation + +Move legacy files from root into `docs/history/`: + +| Current path | New path | +|---|---| +| `legacy_CHANGELOG.md` | `docs/history/CHANGELOG-v3.md` | +| (current README.md content) | `docs/history/README-v3.md` | + +`README-v3.md` gets a header: "This documents Apartment v3.x. For v4, see [README.md](../../README.md)." + +`CHANGELOG-v3.md` gets a similar header pointing to GitHub Releases for v4+. + +### 2. `README.md` — v4 Rewrite + +Complete rewrite oriented around v4's mental model. Structure: + +``` +# Apartment + [badges] + One-liner + code example + +## When to Use Apartment + [keep current comparison table — it's good] + +## About ros-apartment + [keep, minor update: note v4 rewrite] + +## Installation + Requirements (Ruby 3.3+, Rails 7.2+, PG 14+/MySQL 8.4+/SQLite3) + Gemfile + generator command + +## Quick Start + Apartment.configure block with: + - config.tenant_strategy (required, :schema or :database_name) + - config.tenants_provider (required, callable) + - config.default_tenant + Invariant: "Tenant context is block-scoped; prefer Tenant.switch { ... } in app code." + Apartment::Tenant.create / switch / drop examples + Apartment::Model + pin_tenant for global models + +## Configuration Reference + ### Required Options + tenant_strategy, tenants_provider + ### Pool Settings + tenant_pool_size, pool_idle_timeout, max_total_connections + ### Elevator (Request Tenant Detection) + config.elevator, config.elevator_options + Note: Railtie auto-inserts middleware; no manual insertion needed + ### Migrations + parallel_migration_threads, schema_load_strategy, seed_after_create + ### RBAC + migration_role, app_role, environmentify_strategy + ### PostgreSQL + configure_postgres { |pg| pg.persistent_schemas = [...] } + Shared extensions setup (keep existing rake example) + ### MySQL + configure_mysql { |my| ... } + +## Elevators + [keep current content, update examples to v4 patterns] + Note Railtie auto-insertion + Custom elevator example + +## Pinned Models (Global Tables) + Replaces "Excluded Models" section. + + Models that live in the default schema (not per-tenant): + include Apartment::Model + pin_tenant + + Why pin_tenant over excluded_models: + - Declarative: the model itself declares its tenancy, not a config list + - Zeitwerk-safe: works with autoloading (no string-to-class resolution at boot) + - Composable: works with connected_to(role:) for read replicas + + Association rule: use has_many :through, not HABTM. + + connected_to compatibility: + Pinned models work correctly inside connected_to(role: :reading) blocks. + The pin bypasses Apartment's tenant routing; Rails' own role routing + takes over. No special handling needed. + + This applies to the common case where ApplicationRecord declares + connects_to with multiple roles on the same database. For models + that use connects_to to point at a separate database entirely, + see Known Limitations below. + +## Callbacks + [keep current content, same API] + +## Migrations + [keep content, update rake task names to apartment:migrate etc.] + Parallel migrations section (keep) + +## Known Limitations + ### connects_to with Separate Databases + If a model (or its abstract base class) uses connects_to to point at + a completely different database (not just different roles on the same DB), + Apartment's connection_pool patch will try to create a tenant pool for it. + + Workaround: pin_tenant on the abstract class or model that declares + connects_to to a separate database. + + Note: The common pattern of ApplicationRecord using connects_to with + multiple roles (writing/reading) on the same database works correctly — + Apartment keys pools by "tenant:role" and respects Rails' role routing. + +## Background Workers + [keep, update examples to block-based switch] + +## Rails Console + [keep current content] + +## Troubleshooting + [keep, remove any v3-only items] + +## Upgrading from v3 + Link to docs/upgrading-to-v4.md + +## Contributing + [keep, update test commands if needed] + +## License + [keep] +``` + +Things not shown in README (documented in upgrade guide only): +- `config.excluded_models` (deprecated in v4, removed in v5; upgrade guide covers migration) +- `config.tenant_names` (removed in v4) +- `config.use_schemas` / `config.use_sql` (replaced by `tenant_strategy` and `schema_load_strategy`) +- `switch!` recommendation (mention it exists for console use, discourage in app code) +- Multi-server setup (not yet ported to v4; omit rather than document unimplemented feature) + +### 3. `docs/upgrading-to-v4.md` — Upgrade Guide + +Self-sufficient guide for external ros-apartment users upgrading from v3 to v4. + +Structure: + +``` +# Upgrading to Apartment v4 + +## Requirements + Ruby 3.3+, Rails 7.2+, PostgreSQL 14+, MySQL 8.4+, SQLite3 + +## What Changed and Why + Brief (3-4 sentences): pool-per-tenant replaces thread-local switching, + fiber-safe via CurrentAttributes, immutable config after boot, declarative + model pinning. + +## Breaking Changes + + ### Configuration + - tenant_names removed; use tenants_provider (must be callable) + - tenant_strategy required (:schema or :database_name) + - use_schemas / use_sql removed; use tenant_strategy + schema_load_strategy + - Config is frozen after Apartment.configure — no runtime mutation + + ### Tenant API + - Apartment::Tenant.switch requires a block (no manual switch/reset pattern) + - switch! exists for console/REPL but is discouraged in app code + - Apartment::Tenant.current is unchanged (same name in v3 and v4) + + ### Models + - config.excluded_models deprecated (still works in v4, removed in v5) + - Migrate to: include Apartment::Model + pin_tenant on each global model + - process_excluded_models deprecated; use process_pinned_models + + ### Middleware + - Railtie auto-inserts elevator middleware; remove manual + config.middleware.use/insert_before lines + - Configure via config.elevator = :subdomain (symbol, not class) + + ### Connection Model + - Pool-per-tenant replaces thread-local switching + - Fiber-safe via CurrentAttributes (set isolation_level: :fiber if using fibers) + - Each tenant gets a dedicated connection pool + +## Migration Steps + + ### Step 1: Update Configuration + Before/after config comparison + + ### Step 2: Update Models + For each model in config.excluded_models: + 1. Add include Apartment::Model + 2. Add pin_tenant + 3. Remove from config.excluded_models list + Delete the config.excluded_models line. + + ### Step 3: Update Tenant Switching + Find/replace patterns: + switch!(t) ... ensure reset → switch(t) { ... } + switch_to(t) ... ensure reset! → switch(t) { ... } + Note: Apartment::Tenant.current is unchanged between v3 and v4. + + ### Step 4: Update Middleware + Remove manual middleware insertion. + Set config.elevator = :subdomain (or appropriate symbol). + + ### Step 5: Update Background Jobs + Block-scoped switching in Sidekiq/ActiveJob workers. + + ### Step 6: Update Tests + before { Apartment::Tenant.reset } (no bang) + Block-based switching in specs + + ### Step 7: Verify + Run full test suite + Check connection pool behavior in staging + +## connects_to Compatibility + Same content as README "Known Limitations" section, with more detail: + - The common case (ApplicationRecord connects_to with roles) works + - Edge case: model with connects_to to a separate database needs pin_tenant + - connected_to(role: :reading) works correctly with pinned models + +## Troubleshooting + Port useful items from PR #327 (corrected): + - "No connection defined for tenant" → check tenants_provider + - Connection pool sizing → tenant_pool_size, max_total_connections + - Thread safety → always use block-scoped switch + Remove: inflated benchmarks, emoji headers, inaccurate API claims +``` + +### 4. Install Template Update + +`lib/generators/apartment/install/templates/apartment.rb` line 21 area. + +Current: +```ruby +# Models that live in the shared/default schema (not per-tenant). +# config.excluded_models = %w[Account] +``` + +Replace with: +```ruby +# Models that live in the shared/default schema (not per-tenant). +# The recommended approach is to declare this in the model itself: +# +# class Account < ApplicationRecord +# include Apartment::Model +# pin_tenant +# end +# +# Legacy alternative (deprecated in v4, removed in v5): +# config.excluded_models = %w[Account] +``` + +### 5. `RELEASING.md` — Dual Release Process + +Add a section covering the v3 maintenance track: + +``` +## Dual Release (v4 + v3 maintenance) + +While v3 is still supported, maintenance releases (bug fixes, security +patches) are cut from the `3-4-stable` branch. + +### v4 releases +Tag-based publishing from stable branches. See RELEASING.md for full process. + +### v3 maintenance releases + +The gem-publish workflow currently triggers on push to `main` only. +For v3 releases, we add a tag trigger to the workflow: + +```yaml +on: + push: + branches: [ 'main' ] + tags: [ 'v3.*' ] +``` + +GitHub Actions `*` matches any character sequence including dots, so `v3.*` +matches `v3.4.2`. Only maintainers should push v3 tags; consider using +GitHub's environment protection rules on the `production` environment as +an additional safeguard. + +Release steps: +1. Create/checkout `3-4-stable` branch (branched from last v3 release tag) +2. Cherry-pick or apply fixes (e.g., PRs #340, #342) +3. Bump version in lib/apartment/version.rb (e.g., 3.4.2) +4. Push 3-4-stable to origin +5. Tag and push: `git tag v3.4.2 && git push origin v3.4.2` + - The tag push triggers gem-publish, which checks out the tag + - Do NOT merge 3-4-stable into main (main contains v4 code) +6. Create GitHub Release from the v3.4.2 tag, noting it as a maintenance release + +### Version coordination +- v4 uses 4.x.y version numbers +- v3 maintenance uses 3.4.x version numbers +- Both publish to the same ros-apartment gem on RubyGems +- RubyGems resolves via version constraints in user Gemfiles + +### End of v3 support +When v3 maintenance ends, delete the 3-4-stable branch and remove this +section from RELEASING.md. +``` + +### 6. CLAUDE.md Updates + +#### Root `CLAUDE.md` + +- **Core Concepts**: Add `pin_tenant` mention: "**Pinned models**: Global tables declared with `Apartment::Model` + `pin_tenant`. Replaces `excluded_models`." +- **Commands**: Verify test counts are current; update if spec counts changed since last edit. +- **Gotchas**: Add note about `connects_to` edge case. + +#### `lib/apartment/CLAUDE.md` + +Add to directory structure: +``` +├── concerns/ # ActiveRecord concerns for tenant-aware models +│ └── model.rb # Apartment::Model concern: pin_tenant, apartment_pinned? +``` + +Add file description section: +``` +### concerns/model.rb — Model Pinning Concern + +`Apartment::Model` provides `pin_tenant` (class method) to declare a model +as pinned to the default tenant. Registered models bypass the ConnectionHandling +patch. Zeitwerk-safe: works whether called before or after activate!. +`apartment_pinned?` checks the class and its superclass chain. +``` + +#### `spec/CLAUDE.md` + +Add to the unit test file listing: +``` +- `concerns/model_spec.rb` - Apartment::Model concern (pin_tenant, apartment_pinned?, inheritance) +- `patches/connection_handling_spec.rb` - ConnectionHandling patch (tenant pool routing, pinned model bypass, role keying) +``` + +### 7. `gem-publish.yml` — Tag Trigger for v3 Releases + +Add `tags: [ 'v3.*' ]` to the workflow's `on.push` trigger so that v3 maintenance releases can be published from tags without merging into main. + +## Non-Goals + +- No application code changes (workflow config change is the only non-doc change) +- No v3 compatibility shims or deprecation warnings in code +- No multi-server setup documentation (not ported to v4 yet) +- No performance benchmarks (defer until real numbers exist) + +## Open Questions + +None — all resolved during brainstorming. diff --git a/docs/designs/v4-railtie-test-infra.md b/docs/designs/v4-railtie-test-infra.md new file mode 100644 index 00000000..ccdf4f11 --- /dev/null +++ b/docs/designs/v4-railtie-test-infra.md @@ -0,0 +1,413 @@ +# v4 Railtie + Test Infrastructure Overhaul — Design Spec + +## Overview + +This spec covers two tightly coupled deliverables: (1) a minimal v4 Railtie that makes Apartment work in a real Rails app, and (2) a test infrastructure overhaul that validates the Railtie works end-to-end and makes the existing test suite more hermetic and comprehensive. + +**Primary goal:** After this work, CampusESP (and any Rails app) can boot with Apartment v4 by adding an initializer and having the Railtie wire everything up automatically. + +**Secondary goal:** The test suite becomes scenario-driven, leak-proof, and instrumented with coverage and profiling tools. + +## 1. Minimal v4 Railtie + +### What it does + +The Railtie is the bridge between `Apartment.configure` (user's initializer) and Rails boot. It does NOT configure Apartment — it wires up the result of configuration. + +Three hooks, in Rails boot order: + +1. **`config.after_initialize`** — After all initializers have run: + - Guard: skip if `Apartment.config.nil?` (the `@config` ivar is nil until `Apartment.configure` is called — this is the correct predicate) + - Warn if `ActiveSupport::IsolatedExecutionState.isolation_level` is `:thread` instead of `:fiber` — v4 requires fiber isolation for correct `CurrentAttributes` propagation to `load_async` threads (per v4 design doc) + - Call `Apartment.activate!` (prepends ConnectionHandling on AR::Base) + - Call `Apartment::Tenant.init` (processes excluded models) + - Rescue `ActiveRecord::NoDatabaseError` for `db:create` compatibility (database may not exist yet) + +2. **`config.app_middleware.use`** — Insert elevator middleware: + - Only if `Apartment.config&.elevator` is set + - Resolution mechanism: `"Apartment::Elevators::#{elevator.to_s.camelize}".constantize`. If the class doesn't exist, raises `ConfigurationError` with a clear message listing available elevators. + - Passes `Apartment.config.elevator_options` to the elevator constructor + +3. **`rake_tasks`** — Load v4 rake tasks: + - `apartment:create` — creates all tenants from `tenants_provider` + - `apartment:drop` — drops a named tenant + - `apartment:migrate` — runs migrations for all tenants + - `apartment:seed` — seeds all tenants + - `apartment:rollback` — rolls back migrations for all tenants + +### Schema loading during tenant creation + +`AbstractAdapter#create` currently only runs DDL (`CREATE SCHEMA/DATABASE`). New tenants are empty. After this work, `create` also loads the schema into the new tenant: + +```ruby +def create(tenant) + run_callbacks(:create) do + create_tenant(tenant) + import_schema(tenant) if Apartment.config.schema_load_strategy + seed(tenant) if Apartment.config.seed_after_create # uses existing public seed method + Instrumentation.instrument(:create, tenant: tenant) + end +end +``` + +Schema loading strategy (new config option `schema_load_strategy`): +- `:schema_rb` (default) — `load(schema_file)` inside a tenant switch block +- `:sql` — `ActiveRecord::Tasks::DatabaseTasks.load_schema(config, :sql)` for `structure.sql` +- `nil` — skip schema loading (raw DDL only, current behavior) + +The schema file path defaults to `db/schema.rb` (or `db/structure.sql`), configurable via `config.schema_file`. + +**Note:** `seed_after_create` already exists in `Config` (added in Phase 1). No new config attribute needed for seeding. + +**Failure handling for `import_schema`:** If schema loading fails after `create_tenant` DDL succeeded, the method raises `Apartment::SchemaLoadError` (wrapping the original exception). The tenant DDL artifact (schema/database/file) is left in place — the caller can decide to `drop` it. We do NOT auto-rollback because: (a) the tenant may be partially usable, (b) auto-drop could mask the real error, (c) the user may want to inspect the broken tenant. The error message includes both the tenant name and the original exception for clear debugging. + +### User-facing configuration + +Users configure Apartment in an initializer, then the Railtie wires it up: + +```ruby +# config/initializers/apartment.rb +Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { Company.pluck(:subdomain) } + config.default_tenant = "public" + config.excluded_models = %w[User Company] + config.elevator = :subdomain + config.configure_postgres do |pg| + pg.persistent_schemas = %w[extensions] + end +end +``` + +No other setup needed — the Railtie handles `activate!`, `init`, and middleware. + +### File changes + +- Replace `lib/apartment/railtie.rb` (currently v3, Zeitwerk-ignored) with v4 implementation +- Remove `railtie` from Zeitwerk ignore list in `lib/apartment.rb` +- Add `lib/apartment/tasks/v4.rake` for v4 rake tasks +- Add `schema_load_strategy` and `schema_file` to `Config` +- Add `import_schema` private method to `AbstractAdapter` + +## 2. Tenant Name Validation + +### Design + +New module `Apartment::TenantNameValidator` — pure functions, no IO, no DB calls. + +```ruby +module Apartment + module TenantNameValidator + module_function + + def validate!(name, strategy:, adapter_name: nil) + validate_common!(name) + case strategy + when :schema + validate_postgresql_identifier!(name) + when :database_name + case adapter_name + when /mysql/, /trilogy/ then validate_mysql_database_name!(name) + when /postgresql/, /postgis/ then validate_postgresql_identifier!(name) + when /sqlite/ then validate_sqlite_path!(name) + end + end + # :shard and :database_config strategies use common validation only. + # Engine-specific validation is deferred until those strategies are implemented. + end + end +end +``` + +### Rules + +**Common (all engines):** +- Must be a non-empty string +- No NUL bytes (`\x00`) +- No whitespace +- Max 255 characters (catch-all) + +**PostgreSQL identifiers (schema names, database names):** +- Max 63 characters (NAMEDATALEN - 1) +- Must match `[a-zA-Z_][a-zA-Z0-9_-]*` — hyphens are allowed but require quoting. Our adapters already quote via `quote_table_name`, so hyphens are safe. +- Cannot start with `pg_` (reserved prefix) + +**MySQL database names:** +- Max 64 characters +- Allowed: `[a-zA-Z0-9_$-]` +- Cannot start with a digit +- No `/`, `\`, `.` at end + +**SQLite file paths:** +- No path traversal (`..`, `/` outside base directory) +- Filesystem-safe characters + +### Enforcement mechanism + +Validation is called from `AbstractAdapter`, not from subclasses. To guarantee it always runs, `AbstractAdapter` wraps the abstract `resolve_connection_config` in a template method: + +```ruby +# AbstractAdapter +def validated_connection_config(tenant) + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: base_config['adapter'] + ) + resolve_connection_config(tenant) +end +``` + +`ConnectionHandling#connection_pool` calls `validated_connection_config` instead of `resolve_connection_config` directly. `AbstractAdapter#create` also calls `validate!` before `create_tenant`. Subclasses continue to override `resolve_connection_config` — the validation wrapper is transparent. + +### Where it's called + +- `AbstractAdapter#create` — always validates before DDL. Raises `ConfigurationError` on invalid name. +- `AbstractAdapter#validated_connection_config` — called by `ConnectionHandling`. In-memory only. +- NOT called during `switch` or `switch!` — these just set `Current.tenant` and the pool lookup handles the rest. The validation happens when the pool is first created (via `validated_connection_config` in `ConnectionHandling`). + +### What it's NOT + +- NOT an existence check (no DB query to see if tenant exists) +- NOT a `tenant_presence_check` (the v3 setting that was turned off for performance) +- Just a format/safety check on the string itself + +## 3. ConnectionHandler Swap in Tests + +### Problem + +Current integration tests share a single `ActiveRecord::Base.connection_handler`. Pools registered by one test leak into the next. `Apartment.clear_config` deregisters shards but doesn't replace the handler. This causes subtle inter-test dependencies. + +### Solution + +Adopt the `activerecord-tenanted` pattern: swap in a fresh `ConnectionHandler` per test. + +In `spec/integration/v4/support.rb`, add an RSpec configuration hook: + +```ruby +RSpec.configure do |config| + config.around(:each, :integration) do |example| + old_handler = ActiveRecord::Base.connection_handler + new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + ActiveRecord::Base.connection_handler = new_handler + # Re-establish the default connection on the fresh handler + ActiveRecord::Base.establish_connection(...) + example.run + ensure + # Disconnect all pools on the temporary handler before discarding it. + # Without this, connections opened during the test remain open on the + # database server until the process exits or the GC collects them. + new_handler&.clear_all_connections! + ActiveRecord::Base.connection_handler = old_handler + end +end +``` + +This ensures: +- Each test gets a pristine handler with no leftover shard registrations +- Pool leakage between tests is impossible +- `Apartment.clear_config` in `after` blocks becomes a secondary cleanup, not the only defense + +### Impact on existing tests + +All `spec/integration/v4/` tests already use the `:integration` tag. The swap is transparent — tests don't need to change. The `before`/`after` blocks that call `Apartment.clear_config` remain as belt-and-suspenders cleanup. + +## 4. Scenario-Based Database Configs + +### Problem + +`V4IntegrationHelper.default_connection_config` returns one config per engine. Tests can't easily run the same assertions against different strategies (e.g., PG schema vs PG database-per-tenant). + +### Solution + +Add scenario configs as YAML files: + +``` +spec/integration/v4/scenarios/ +├── postgresql_schema.yml # schema-per-tenant (most common PG config) +├── postgresql_database.yml # database-per-tenant on PG +├── mysql_database.yml # database-per-tenant on MySQL +└── sqlite_file.yml # file-per-tenant +``` + +Each YAML defines: adapter, strategy, connection params, default_tenant, and any adapter-specific config (persistent_schemas, environmentify, etc.). + +`V4IntegrationHelper` gains: + +```ruby +def self.scenarios_for_engine + # Returns available scenarios for current DATABASE_ENGINE +end + +def self.with_scenario(name) + # Loads scenario config + # Configures Apartment + # Builds adapter + # Yields + # Cleans up +end + +def self.each_scenario(&block) + # Iterates over all scenarios for current engine + # Used for cross-scenario test generation +end +``` + +Tests that should run against multiple scenarios use: + +```ruby +V4IntegrationHelper.each_scenario do |scenario| + context "with #{scenario.name}" do + before { scenario.setup! } + after { scenario.teardown! } + + it "isolates data" do + # ... + end + end +end +``` + +Tests that are engine-specific (e.g., PG schema search_path) continue to use the `if: V4IntegrationHelper.postgresql?` guard and configure directly. + +## 5. Dummy App Upgrade to v4 + +### Current state + +`spec/dummy/` is a v3 Rails app with: +- Company (excluded model), User, Book models +- PostgreSQL database.yml (`apartment_postgresql_test`) +- v3 `Apartment.configure` initializer +- Subdomain elevator middleware +- Migrations, seeds, schema.rb + +### Changes + +1. **Replace initializer** — `config/initializers/apartment.rb` uses v4 `Apartment.configure` block +2. **Railtie wires it up** — the v4 Railtie handles `activate!`, `init`, middleware +3. **Add a test controller** — `TenantsController#show` returns `{ tenant: Apartment::Tenant.current, user_count: User.count }` as JSON +4. **Add route** — `get '/tenant_info' => 'tenants#show'` +5. **Update database.yml** — ensure it works with the v4 config system +6. **Update config/application.rb** — remove v3-specific requires, load v4 + +### Request-lifecycle test + +New file `spec/integration/v4/request_lifecycle_spec.rb`: + +```ruby +# Boots the dummy app, sends HTTP requests through the elevator, +# verifies tenant switching + data isolation in the response. + +RSpec.describe 'Request lifecycle', :request_lifecycle do + include Rack::Test::Methods + + def app + Dummy::Application + end + + it 'elevator switches tenant based on subdomain' do + header 'Host', 'acme.example.com' + get '/tenant_info' + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body['tenant']).to eq('acme') + end + + it 'data is isolated between tenants' do + # Create user in tenant A + Apartment::Tenant.switch('acme') { User.create!(name: 'Alice') } + + # Request tenant A — should see 1 user + header 'Host', 'acme.example.com' + get '/tenant_info' + expect(JSON.parse(last_response.body)['user_count']).to eq(1) + + # Request tenant B — should see 0 users + header 'Host', 'widgets.example.com' + get '/tenant_info' + expect(JSON.parse(last_response.body)['user_count']).to eq(0) + end + + it 'tenant context is cleaned up after request' do + header 'Host', 'acme.example.com' + get '/tenant_info' + expect(Apartment::Tenant.current).to eq(Apartment.config.default_tenant) + end +end +``` + +These tests require real PostgreSQL (the dummy app uses PG). They run via: +```bash +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/request_lifecycle_spec.rb +``` + +## 6. Coverage + TestProf + +### SimpleCov + +Add to `Gemfile` development group: +```ruby +gem 'simplecov', require: false +``` + +Configure in `spec/spec_helper.rb`: +```ruby +if ENV['COVERAGE'] + require 'simplecov' + SimpleCov.start do + add_filter '/spec/' + add_group 'Adapters', 'lib/apartment/adapters' + add_group 'Patches', 'lib/apartment/patches' + add_group 'Core', 'lib/apartment' + minimum_coverage 80 + end +end +``` + +Run: `COVERAGE=1 bundle exec rspec spec/unit/` + +### TestProf + +Add to `Gemfile` development group: +```ruby +gem 'test-prof', require: false +``` + +Initial usage is **profiling only** — identify slow tests and expensive setup patterns: + +```bash +# Profile test suite to find slow examples and expensive let/before blocks +FPROF=1 bundle exec rspec spec/integration/v4/ +EVENT_PROF=sql.active_record bundle exec rspec spec/integration/v4/ +``` + +`let_it_be` and `before_all` are available but deferred until profiling reveals specific tests where they'd help. Don't pre-optimize — add them where profiling shows setup cost dominates. + +## File Map + +### New files + +| File | Purpose | +|------|---------| +| `lib/apartment/railtie.rb` | v4 Railtie (replaces v3) | +| `lib/apartment/tasks/v4.rake` | v4 rake tasks | +| `lib/apartment/tenant_name_validator.rb` | Tenant name format validation | +| `spec/unit/tenant_name_validator_spec.rb` | Validator unit tests | +| `spec/unit/railtie_spec.rb` | Railtie hook tests | +| `spec/integration/v4/request_lifecycle_spec.rb` | Dummy app request tests | +| `spec/integration/v4/scenarios/*.yml` | Database config scenarios | + +### Modified files + +| File | Change | +|------|--------| +| `lib/apartment.rb` | Remove `railtie` from Zeitwerk ignore, add schema_load_strategy to config | +| `lib/apartment/config.rb` | Add `schema_load_strategy`, `schema_file` options | +| `lib/apartment/adapters/abstract_adapter.rb` | Add `import_schema`, `validated_connection_config` wrapper, call validator in `create` | +| `lib/apartment/patches/connection_handling.rb` | Call `validated_connection_config` instead of `resolve_connection_config` | +| `spec/integration/v4/support.rb` | ConnectionHandler swap, scenario loading, `with_scenario` helper | +| `spec/dummy/config/initializers/apartment.rb` | v4 configure block | +| `spec/dummy/config/application.rb` | Remove v3 requires | +| `spec/dummy/app/controllers/tenants_controller.rb` | New test controller | +| `spec/dummy/config/routes.rb` | Add tenant_info route | +| `Gemfile` | Add simplecov, test-prof | 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/CHANGELOG.md b/docs/history/CHANGELOG-v3.md similarity index 99% rename from CHANGELOG.md rename to docs/history/CHANGELOG-v3.md index 4ce60fbe..e98a8362 100644 --- a/CHANGELOG.md +++ b/docs/history/CHANGELOG-v3.md @@ -1,8 +1,12 @@ -# Changelog +> **Note:** This changelog covers Apartment v3.x and earlier. For v4+, see [GitHub Releases](https://github.com/rails-on-services/apartment/releases). -## [Unreleased](https://github.com/rails-on-services/apartment/tree/HEAD) +# Changelog [DEPRECATED] -[Full Changelog](https://github.com/rails-on-services/apartment/compare/v2.8.0...HEAD) +Changes are now being tracked in the [releases](https://github.com/rails-on-services/apartment/releases) section of the repository. + +## [v2.8.1](https://github.com/rails-on-services/apartment/tree/v2.8.1) (2020-12-17) + +[Full Changelog](https://github.com/rails-on-services/apartment/compare/v2.8.0...v2.8.1) **Implemented enhancements:** diff --git a/docs/history/README-v3.md b/docs/history/README-v3.md new file mode 100644 index 00000000..6758de18 --- /dev/null +++ b/docs/history/README-v3.md @@ -0,0 +1,292 @@ +> **Note:** This documents Apartment v3.x. For v4, see [README.md](../../README.md). + +# Apartment + +[![Gem Version](https://badge.fury.io/rb/ros-apartment.svg)](https://badge.fury.io/rb/ros-apartment) +[![CI](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml/badge.svg)](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/rails-on-services/apartment/graph/badge.svg?token=Q4I5QL78SA)](https://codecov.io/gh/rails-on-services/apartment) + +*Database-level multitenancy for Rails and ActiveRecord* + +Apartment isolates tenant data at the **database level** — using PostgreSQL schemas or separate databases — so that tenant data separation is enforced by the database engine, not application code. + +```ruby +Apartment::Tenant.switch('acme') do + User.all # only returns users in the 'acme' schema/database +end +``` + +## When to Use Apartment + +Apartment uses **schema-per-tenant** (PostgreSQL) or **database-per-tenant** (MySQL/SQLite) isolation. This is one of several approaches to multitenancy in Rails. Choose the right one for your situation: + +| Approach | Isolation | Best for | Gem | +|----------|-----------|----------|-----| +| **Row-level** (shared tables, `WHERE tenant_id = ?`) | Application-enforced | Many tenants, greenfield apps, cross-tenant reporting | [`acts_as_tenant`](https://github.com/ErwinM/acts_as_tenant) | +| **Schema-level** (PostgreSQL schemas) | Database-enforced | Fewer high-value tenants, regulatory requirements, retrofitting existing apps | `ros-apartment` | +| **Database-level** (separate databases) | Full isolation | Strictest isolation, per-tenant performance tuning | `ros-apartment` | + +**Use Apartment when** you need hard data isolation between tenants — where a missed `WHERE` clause can't accidentally leak data across tenants. This is common in regulated industries, B2B SaaS with contractual isolation requirements, or when retrofitting an existing single-tenant app. + +**Consider row-level tenancy instead** if you have many tenants (hundreds+), need cross-tenant queries, or are starting a greenfield project. Row-level is simpler, uses fewer database resources, and scales more linearly. See the [Arkency comparison](https://blog.arkency.com/comparison-of-approaches-to-multitenancy-in-rails-apps/) for a thorough analysis. + +## About ros-apartment + +This gem is a maintained fork of the original [Apartment gem](https://github.com/influitive/apartment). Maintained by [CampusESP](https://www.campusesp.com) since 2024. Drop-in replacement — same `require 'apartment'`, same API. + +## Installation + +### Requirements + +- Ruby 3.3+ +- Rails 7.2+ +- PostgreSQL 14+, MySQL 8.4+, or SQLite3 + +### Setup + +```ruby +# Gemfile +gem 'ros-apartment', require: 'apartment' +``` + +```bash +bundle install +bundle exec rails generate apartment:install +``` + +This creates `config/initializers/apartment.rb`. Configure it: + +```ruby +Apartment.configure do |config| + config.excluded_models = ['User', 'Company'] # shared across all tenants + config.tenant_names = -> { Customer.pluck(:subdomain) } +end +``` + +## Usage + +### Creating and Dropping Tenants + +```ruby +Apartment::Tenant.create('acme') # creates schema/database + runs migrations +Apartment::Tenant.drop('acme') # permanently deletes tenant data +``` + +### Switching Tenants + +Always use the block form — it guarantees cleanup even on exceptions: + +```ruby +Apartment::Tenant.switch('acme') do + # all ActiveRecord queries scoped to 'acme' + User.create!(name: 'Alice') +end +# automatically restored to previous tenant +``` + +`switch!` exists for console/REPL use but is discouraged in application code. + +### Switching per Request (Elevators) + +Elevators are Rack middleware that detect the tenant from the request and switch automatically: + +```ruby +# config/application.rb — pick one: +config.middleware.use Apartment::Elevators::Subdomain # acme.example.com → 'acme' +config.middleware.use Apartment::Elevators::Domain # acme.com → 'acme' +config.middleware.use Apartment::Elevators::Host # full hostname matching +config.middleware.use Apartment::Elevators::HostHash, { 'acme.com' => 'acme_tenant' } +config.middleware.use Apartment::Elevators::FirstSubdomain # first subdomain in chain +``` + +**Important:** Position the elevator middleware *before* authentication middleware (e.g., Warden/Devise) to ensure tenant context is established before auth runs: + +```ruby +config.middleware.insert_before Warden::Manager, Apartment::Elevators::Subdomain +``` + +#### Custom Elevator + +```ruby +# app/middleware/my_elevator.rb +class MyElevator < Apartment::Elevators::Generic + def parse_tenant_name(request) + # return tenant name based on request + request.host.split('.').first + end +end +``` + +### Excluded Models + +Models that exist globally (not per-tenant): + +```ruby +config.excluded_models = ['User', 'Company'] +``` + +These models always query the default (public) schema. Use `has_many :through` for associations — `has_and_belongs_to_many` is not supported with excluded models. + +### Excluded Subdomains + +```ruby +Apartment::Elevators::Subdomain.excluded_subdomains = ['www', 'admin', 'public'] +``` + +## Configuration + +All options are set in `config/initializers/apartment.rb`: + +```ruby +Apartment.configure do |config| + # Required: how to discover tenant names (must be a callable) + config.tenant_names = -> { Customer.pluck(:subdomain) } + + # Excluded models — shared across all tenants + config.excluded_models = ['User', 'Company'] + + # Default schema/database (default: 'public' for PostgreSQL) + config.default_tenant = 'public' + + # Prepend Rails environment to tenant names (useful for dev/test) + config.prepend_environment = !Rails.env.production? + + # Seed new tenants after creation + config.seed_after_create = true + + # Enable ActiveRecord query logging with tenant context + config.active_record_log = true +end +``` + +### PostgreSQL-Specific + +```ruby +Apartment.configure do |config| + # Schemas that remain in search_path for all tenants + # (useful for shared extensions like hstore, uuid-ossp) + config.persistent_schemas = ['shared_extensions'] + + # Use raw SQL dumps instead of schema.rb for tenant creation + # (needed for materialized views, custom types, etc.) + config.use_sql = true +end +``` + +#### Setting Up Shared Extensions + +PostgreSQL extensions (hstore, uuid-ossp, etc.) should be installed in a persistent schema: + +```ruby +# lib/tasks/db_enhancements.rake +namespace :db do + task extensions: :environment do + ActiveRecord::Base.connection.execute('CREATE SCHEMA IF NOT EXISTS shared_extensions;') + ActiveRecord::Base.connection.execute('CREATE EXTENSION IF NOT EXISTS HSTORE SCHEMA shared_extensions;') + ActiveRecord::Base.connection.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA shared_extensions;') + end +end + +Rake::Task['db:create'].enhance { Rake::Task['db:extensions'].invoke } +Rake::Task['db:test:purge'].enhance { Rake::Task['db:extensions'].invoke } +``` + +Ensure your `database.yml` includes the persistent schema: + +```yaml +schema_search_path: "public,shared_extensions" +``` + +### Migrations + +Tenant migrations run automatically with `rake db:migrate`. Apartment iterates all tenants from `config.tenant_names`. + +```ruby +# Disable automatic tenant migration if needed +Apartment.db_migrate_tenants = false # in Rakefile, before load_tasks +``` + +#### Parallel Migrations + +For applications with many schemas: + +```ruby +config.parallel_migration_threads = 4 # 0 = sequential (default) +config.parallel_strategy = :auto # :auto, :threads, or :processes +``` + +**Platform notes:** `:auto` uses threads on macOS (libpq fork issues) and processes on Linux. Parallel migrations disable PostgreSQL advisory locks — ensure your migrations are safe to run concurrently. + +### Multi-Server Setup + +Store tenants on different database servers: + +```ruby +config.with_multi_server_setup = true +config.tenant_names = -> { + Tenant.all.each_with_object({}) do |t, hash| + hash[t.name] = { adapter: 'postgresql', host: t.db_host, database: 'postgres' } + end +} +``` + +## Callbacks + +Hook into tenant lifecycle events: + +```ruby +require 'apartment/adapters/abstract_adapter' + +Apartment::Adapters::AbstractAdapter.set_callback :create, :after do |adapter| + # runs after a new tenant is created +end + +Apartment::Adapters::AbstractAdapter.set_callback :switch, :before do |adapter| + # runs before switching tenants +end +``` + +## Background Workers + +For Sidekiq and ActiveJob tenant propagation: + +- [apartment-sidekiq](https://github.com/rails-on-services/apartment-sidekiq) +- [apartment-activejob](https://github.com/rails-on-services/apartment-activejob) + +## Rails Console + +Apartment adds console helpers: + +- `tenant_list` — list available tenants +- `st('tenant_name')` — switch to a tenant + +For a tenant-aware prompt, add `require 'apartment/custom_console'` to `application.rb` (requires `pry-rails`). + +## Troubleshooting + +**Skip initial DB connection on boot:** + +```bash +APARTMENT_DISABLE_INIT=true rails runner 'puts 1' +``` + +**Skip tenant presence check** (saves one query per switch on PostgreSQL): + +```ruby +config.tenant_presence_check = false +``` + +## Contributing + +1. Check [existing issues](https://github.com/rails-on-services/apartment/issues) and [discussions](https://github.com/rails-on-services/apartment/discussions) +2. Fork and create a feature branch +3. Write tests — we don't merge without them +4. Run `bundle exec rspec spec/unit/` and `bundle exec rubocop` +5. Use [Appraisal](https://github.com/thoughtbot/appraisal) to test across Rails versions: `bundle exec appraisal rspec spec/unit/` +6. Submit PR to the `development` branch + +See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. + +## License + +[MIT License](http://www.opensource.org/licenses/MIT) 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/docs/plans/apartment-v4/overview.md b/docs/plans/apartment-v4/overview.md new file mode 100644 index 00000000..d4daba62 --- /dev/null +++ b/docs/plans/apartment-v4/overview.md @@ -0,0 +1,195 @@ +# Apartment v4 Implementation Overview + +> **Spec:** [`docs/designs/apartment-v4.md`](../../designs/apartment-v4.md) + +**Goal:** Ground-up rewrite of ros-apartment with immutable connection-pool-per-tenant architecture, CurrentAttributes-based tenant context, and Rails 7.2/8.0/8.1 support. + +**Approach:** Fresh branch off `development`. v4 alpha branch (`man/spec-restart`) as reference. v3.3-3.4 production-hardened features ported and adapted. TDD throughout. + +## Phase Map + +``` +Phase 1: Foundation + | + v +Phase 2: Adapters & Tenant API + | + +--------+---------+ + | | | + v v v +Phase 3 Phase 4 Phase 5 +Elevators Railtie Job + & Migrations Middleware + | | | + | v | + | Phase 6: | + | CLI & | + | Generator | + | | | + +--------+---------+ + | + v +Phase 7: Integration & Stress Tests + | + v +Phase 8: Docs & Upgrade +``` + +**Note:** Phase 6 depends only on Phase 4 (Thor commands delegate to the Migrator and Railtie). Phases 3 and 5 feed directly into Phase 7. + +## Phases + +### Phase 1: Foundation + +**Branch:** `man/v4-foundation` + +**What:** The core infrastructure everything else builds on. +- `Apartment::Current` (CurrentAttributes) +- `Apartment::Config` (new configuration system) +- Exception hierarchy (`ApartmentError`, `TenantNotFound`, etc.) +- `Apartment::PoolManager` (Concurrent::Map wrapper, fetch_or_create, eviction tracking) +- `Apartment::PoolReaper` (Concurrent::TimerTask, idle eviction, LRU, graceful shutdown) +- Gemspec/version bump to 4.0.0.alpha1 +- AS::Notifications instrumentation points + +**Produces:** A working, tested config + pool management layer with no database dependencies. Everything is unit-testable without PostgreSQL or MySQL. + +**Plan:** [`phase-1-foundation.md`](phase-1-foundation.md) + +--- + +### Phase 2: Adapters & Tenant API + +**Branch:** `man/v4-adapters` + +**Depends on:** Phase 1 + +**What:** The database engine — adapters that create/drop/switch tenants, and the public `Apartment::Tenant` API. +- `Apartment::Adapters::AbstractAdapter` (switch, create, drop, migrate, seed, callbacks) +- `Apartment::Adapters::PostgreSQLAdapter` (schema strategy, resolve_connection_config, schema creation/dropping) +- `Apartment::Adapters::MySQL2Adapter` / `TrilogyAdapter` / `SQLite3Adapter` (database_name strategy) +- `Apartment::Tenant` module (public API: switch, switch!, current, reset, init) +- `Apartment::Patches::ConnectionHandling` (prepend on AR::Base, tenant-aware pool resolution) +- Excluded models processing + +**Produces:** Working tenant switching against real databases. `Apartment::Tenant.switch("acme") { User.count }` works end-to-end. + +**Requires:** PostgreSQL and MySQL in CI for adapter specs. + +--- + +### Phase 3: Elevators + +**Branch:** `man/v4-elevators` + +**Depends on:** Phase 2 + +**What:** Rack middleware for automatic tenant detection from requests. +- `Apartment::Elevators::Generic` (base class, block-scoped switching) +- `Subdomain`, `FirstSubdomain`, `Domain`, `Host`, `HostHash` (ported from v3) +- `Header` (new — trusted header-based tenant resolution) +- `elevator_options` config support + +**Produces:** Working request-level tenant switching. A Rack request with the right subdomain/header automatically sets the tenant context. + +--- + +### Phase 4: Railtie & Migrations + +**Branch:** `man/v4-railtie` + +**Depends on:** Phase 2 + +**What:** Rails integration and migration infrastructure. +- `Apartment::Railtie` (config validation, middleware insertion, job middleware registration, AR patches, isolation_level warning) +- `Apartment::Migrator` (sequential + parallel, threads + processes opt-in, Result tracking) +- Schema dumper patch (Rails 8.1 `public.` prefix stripping, `include_schemas_in_dump`) +- Multi-database rake task enhancement (from v3.4.1) +- Rake task thin wrappers + +**Produces:** `rake apartment:migrate` works. `rails s` boots with automatic tenant middleware. Parallel migrations across tenants. + +--- + +### Phase 5: Job Middleware + +**Branch:** `man/v4-jobs` + +**Depends on:** Phase 2 + +**What:** Background job tenant propagation. +- `Apartment::Jobs::SidekiqMiddleware` (server middleware, CurrentAttributes-based) +- `Apartment::Jobs::SolidQueueHook` +- `Apartment::Jobs::ActiveJobExtension` (around_perform fallback) +- apartment-sidekiq backward compat (job format fallback) + +**Produces:** Jobs enqueued in a tenant context execute in that same tenant context. Zero-config for Sidekiq 7+ and SolidQueue. + +--- + +### Phase 6: CLI & Generator + +**Branch:** `man/v4-cli` + +**Depends on:** Phase 4 + +**What:** Developer tooling. +- `Apartment::CLI` (Thor subclass: create, drop, migrate, rollback, seed, list, current) +- `Apartment::CLI::Pool` (Thor subgroup: stats, evict) +- `bin/apartment` binstub +- `rails generate apartment:install` (initializer template with annotated defaults) + +**Produces:** `apartment migrate`, `apartment pool:stats`, `rails generate apartment:install` all work. + +--- + +### Phase 7: Integration & Stress Tests + +**Branch:** `man/v4-integration-tests` + +**Depends on:** Phases 1-6 + +**What:** Cross-cutting validation. +- Connection pool isolation spec (prove no cross-tenant leakage) +- Thread safety spec (concurrent threads, correct tenant per thread) +- Fiber safety spec (CurrentAttributes isolation across fibers) +- Request lifecycle spec (full Rack request -> tenant -> response -> cleanup) +- Pool eviction spec (idle timeout, LRU, max_total_connections) +- Memory stability spec (no pool/connection leaks under sustained load) +- Appraisals setup (Rails 7.2/8.0/8.1 x PostgreSQL/MySQL/SQLite3) +- CI workflow (GitHub Actions matrix: Ruby 3.3/3.4 x Rails x DB) + +**Produces:** Confidence that the system works correctly under real-world conditions. + +--- + +### Phase 8: Docs & Upgrade + +**Branch:** `man/v4-docs` + +**Depends on:** Phases 1-7 + +**What:** User-facing documentation and migration support. +- `docs/upgrading-to-v4.md` (checklist format, v3 -> v4 mapping) +- README.md rewrite for v4 +- CLAUDE.md updates throughout `lib/` and `spec/` +- v3.5.0 deprecation bridge PR (separate branch off `development`, targets v3.x) + +**Produces:** Users can upgrade from v3 to v4 with clear guidance. + +## Build Order Rationale + +- Phase 1 is pure Ruby — no database, no Rails, fully unit-testable. Fast to build, validates core abstractions early. +- Phase 2 is the critical path — this is where the pool-per-tenant architecture meets real databases. Most risk lives here. +- Phases 3/4/5 are independent consumers of the Phase 2 API. They can be built in parallel by different agents or sequentially. +- Phase 6 depends on Phase 4 because Thor commands delegate to the Migrator and Railtie. +- Phase 7 is deliberately last — it tests the integrated system, not individual components. +- Phase 8 can start as soon as the API stabilizes (after Phase 2) but ships last. + +## Version Strategy + +- Each phase merges to `man/v4` (long-lived feature branch off `development`) +- Alpha releases after Phase 2: `4.0.0.alpha1` +- Beta after Phase 5: `4.0.0.beta1` +- RC after Phase 7: `4.0.0.rc1` +- Final after Phase 8: `4.0.0` diff --git a/docs/plans/apartment-v4/phase-1-foundation.md b/docs/plans/apartment-v4/phase-1-foundation.md new file mode 100644 index 00000000..a403f2e9 --- /dev/null +++ b/docs/plans/apartment-v4/phase-1-foundation.md @@ -0,0 +1,1444 @@ +# Phase 1: Foundation — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the core infrastructure that everything else depends on — config, tenant context, error hierarchy, pool management, and pool eviction — with no database or Rails dependencies in the unit tests. + +**Architecture:** `Apartment::Current` (CurrentAttributes) holds tenant state. `Apartment::Config` validates and stores configuration. `Apartment::PoolManager` wraps a `Concurrent::Map` for thread-safe pool tracking with access timestamps. `Apartment::PoolReaper` uses `Concurrent::TimerTask` for idle eviction and LRU cleanup. + +**Tech Stack:** Ruby 3.3+, ActiveSupport 7.2+, concurrent-ruby, Zeitwerk, RSpec + +**Spec:** [`docs/designs/apartment-v4.md`](../../designs/apartment-v4.md) + +**Reference:** `man/spec-restart` branch has working implementations of Config, Current, and connection adapters. Use as reference, not as copy source — our design spec diverges in several areas (pool eviction, Ruby/Rails minimums, config attributes). + +--- + +## File Map + +### New files (create) + +| File | Responsibility | +|------|---------------| +| `lib/apartment.rb` | Main module, configure DSL, delegators | +| `lib/apartment/version.rb` | `VERSION = '4.0.0.alpha1'` | +| `lib/apartment/current.rb` | `ActiveSupport::CurrentAttributes` subclass | +| `lib/apartment/config.rb` | Configuration object with validation | +| `lib/apartment/configs/postgresql_config.rb` | PostgreSQL-specific config | +| `lib/apartment/configs/mysql_config.rb` | MySQL-specific config | +| `lib/apartment/errors.rb` | Exception hierarchy | +| `lib/apartment/pool_manager.rb` | `Concurrent::Map` wrapper with timestamps | +| `lib/apartment/pool_reaper.rb` | `Concurrent::TimerTask` eviction | +| `lib/apartment/instrumentation.rb` | AS::Notifications event helpers | +| `ros-apartment.gemspec` | Gem metadata and dependencies | +| `spec/unit/current_spec.rb` | CurrentAttributes behavior | +| `spec/unit/config_spec.rb` | Configuration validation | +| `spec/unit/errors_spec.rb` | Exception hierarchy | +| `spec/unit/pool_manager_spec.rb` | Pool tracking, fetch_or_create, eviction | +| `spec/unit/pool_reaper_spec.rb` | Timer-based eviction | +| `spec/unit/instrumentation_spec.rb` | Notification events | +| `spec/spec_helper.rb` | RSpec config for v4 | + +### Modified files + +| File | Change | +|------|--------| +| `Gemfile` | Update for v4 dependencies | +| `.ruby-version` | `3.3.x` | + +--- + +## Task 1: Project scaffold and gemspec + +**Files:** +- Create: `lib/apartment/version.rb` +- Create: `ros-apartment.gemspec` +- Modify: `Gemfile` + +- [ ] **Step 1: Create version file** + +```ruby +# lib/apartment/version.rb +# frozen_string_literal: true + +module Apartment + VERSION = '4.0.0.alpha1' +end +``` + +- [ ] **Step 2: Create gemspec** + +```ruby +# ros-apartment.gemspec +# frozen_string_literal: true + +require_relative 'lib/apartment/version' + +Gem::Specification.new do |s| + s.name = 'ros-apartment' + s.version = Apartment::VERSION + + s.authors = ['Ryan Brunner', 'Brad Robertson', 'Rui Baltazar', 'Mauricio Novelo'] + s.summary = 'A Ruby gem for managing database multi-tenancy. Apartment Gem drop in replacement' + s.description = 'Apartment allows Rack applications to deal with database multi-tenancy through ActiveRecord' + s.email = ['ryan@influitive.com', 'brad@influitive.com', 'rui.p.baltazar@gmail.com', 'mauricio@campusesp.com'] + s.files = %w[ros-apartment.gemspec README.md] + `git ls-files -- lib`.split("\n") + s.executables = s.files.grep(%r{^bin/}).map { |f| File.basename(f) } + + s.licenses = ['MIT'] + s.metadata = { + 'homepage_uri' => 'https://github.com/rails-on-services/apartment', + 'bug_tracker_uri' => 'https://github.com/rails-on-services/apartment/issues', + 'changelog_uri' => 'https://github.com/rails-on-services/apartment/releases', + 'source_code_uri' => 'https://github.com/rails-on-services/apartment', + 'rubygems_mfa_required' => 'true', + } + + s.required_ruby_version = '>= 3.3' + + s.add_dependency('activerecord', '>= 7.2.0', '< 8.2') + s.add_dependency('activesupport', '>= 7.2.0', '< 8.2') + s.add_dependency('concurrent-ruby', '>= 1.3.0') + s.add_dependency('parallel', '>= 1.26.0') + s.add_dependency('public_suffix', '>= 2.0.5', '< 7') + s.add_dependency('rack', '>= 3.0.9', '< 4.0') + s.add_dependency('thor', '>= 1.3.0') + s.add_dependency('zeitwerk', '>= 2.7.1') +end +``` + +- [ ] **Step 3: Update Gemfile for v4 development** + +Replace the development dependencies section to match v4 needs. Keep the existing appraisal structure but update Rails version constraints. + +- [ ] **Step 4: Verify gem loads** + +Run: `ruby -e "require_relative 'lib/apartment/version'; puts Apartment::VERSION"` +Expected: `4.0.0.alpha1` + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/version.rb ros-apartment.gemspec Gemfile +git commit -m "Scaffold v4 gemspec and version (4.0.0.alpha1)" +``` + +--- + +## Task 2: Error hierarchy + +**Files:** +- Create: `lib/apartment/errors.rb` +- Create: `spec/unit/errors_spec.rb` + +- [ ] **Step 1: Write the failing test** + +```ruby +# spec/unit/errors_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Apartment error hierarchy' do + it 'defines ApartmentError as base' do + expect(Apartment::ApartmentError).to be < StandardError + end + + it 'defines TenantNotFound' do + expect(Apartment::TenantNotFound).to be < Apartment::ApartmentError + end + + it 'defines TenantExists' do + expect(Apartment::TenantExists).to be < Apartment::ApartmentError + end + + it 'defines AdapterNotFound' do + expect(Apartment::AdapterNotFound).to be < Apartment::ApartmentError + end + + it 'defines ConfigurationError' do + expect(Apartment::ConfigurationError).to be < Apartment::ApartmentError + end + + it 'defines PoolExhausted' do + expect(Apartment::PoolExhausted).to be < Apartment::ApartmentError + end + + it 'defines SchemaLoadError' do + expect(Apartment::SchemaLoadError).to be < Apartment::ApartmentError + end + + it 'includes tenant name in TenantNotFound message' do + error = Apartment::TenantNotFound.new('acme') + expect(error.message).to include('acme') + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/errors_spec.rb` +Expected: FAIL — `uninitialized constant Apartment::ApartmentError` + +- [ ] **Step 3: Write minimal implementation** + +```ruby +# lib/apartment/errors.rb +# frozen_string_literal: true + +module Apartment + class ApartmentError < StandardError; end + + class TenantNotFound < ApartmentError + def initialize(tenant = nil) + super(tenant ? "Tenant '#{tenant}' not found" : 'Tenant not found') + end + end + + class TenantExists < ApartmentError + def initialize(tenant = nil) + super(tenant ? "Tenant '#{tenant}' already exists" : 'Tenant already exists') + end + end + + class AdapterNotFound < ApartmentError; end + class ConfigurationError < ApartmentError; end + class PoolExhausted < ApartmentError; end + class SchemaLoadError < ApartmentError; end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/errors_spec.rb` +Expected: 8 examples, 0 failures + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/errors.rb spec/unit/errors_spec.rb +git commit -m "Add v4 exception hierarchy" +``` + +--- + +## Task 3: Apartment::Current (CurrentAttributes) + +**Files:** +- Create: `lib/apartment/current.rb` +- Create: `spec/unit/current_spec.rb` + +- [ ] **Step 1: Write the failing test** + +```ruby +# spec/unit/current_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Apartment::Current do + after { described_class.reset } + + describe '.tenant' do + it 'defaults to nil' do + expect(described_class.tenant).to be_nil + end + + it 'can be set and read' do + described_class.tenant = 'acme' + expect(described_class.tenant).to eq('acme') + end + end + + describe '.previous_tenant' do + it 'defaults to nil' do + expect(described_class.previous_tenant).to be_nil + end + + it 'can be set and read' do + described_class.previous_tenant = 'old_tenant' + expect(described_class.previous_tenant).to eq('old_tenant') + end + end + + describe '.reset' do + it 'clears tenant and previous_tenant' do + described_class.tenant = 'acme' + described_class.previous_tenant = 'old' + described_class.reset + expect(described_class.tenant).to be_nil + expect(described_class.previous_tenant).to be_nil + end + end + + describe 'thread isolation' do + it 'isolates tenant across threads' do + described_class.tenant = 'main_thread' + + thread_tenant = nil + Thread.new { + thread_tenant = described_class.tenant + }.join + + expect(described_class.tenant).to eq('main_thread') + expect(thread_tenant).to be_nil + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/current_spec.rb` +Expected: FAIL — `uninitialized constant Apartment::Current` + +- [ ] **Step 3: Write minimal implementation** + +```ruby +# lib/apartment/current.rb +# frozen_string_literal: true + +require 'active_support/current_attributes' + +module Apartment + class Current < ActiveSupport::CurrentAttributes + attribute :tenant + attribute :previous_tenant + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/current_spec.rb` +Expected: 5 examples, 0 failures + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/current.rb spec/unit/current_spec.rb +git commit -m "Add Apartment::Current via ActiveSupport::CurrentAttributes" +``` + +--- + +## Task 4: Apartment::Config + +**Files:** +- Create: `lib/apartment/config.rb` +- Create: `lib/apartment/configs/postgresql_config.rb` +- Create: `lib/apartment/configs/mysql_config.rb` +- Create: `spec/unit/config_spec.rb` + +- [ ] **Step 1: Write the failing test** + +```ruby +# spec/unit/config_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Apartment::Config do + subject(:config) { described_class.new } + + describe 'defaults' do + it { expect(config.default_tenant).to be_nil } + it { expect(config.tenant_pool_size).to eq(5) } + it { expect(config.pool_idle_timeout).to eq(300) } + it { expect(config.max_total_connections).to be_nil } + it { expect(config.excluded_models).to eq([]) } + it { expect(config.persistent_schemas).to eq([]) } + it { expect(config.seed_after_create).to eq(false) } + it { expect(config.parallel_migration_threads).to eq(0) } + it { expect(config.parallel_strategy).to eq(:auto) } + it { expect(config.environmentify_strategy).to be_nil } + it { expect(config.elevator).to be_nil } + it { expect(config.elevator_options).to eq({}) } + it { expect(config.tenant_not_found_handler).to be_nil } + end + + describe '#tenant_strategy=' do + it 'accepts valid strategies' do + %i[schema database_name shard database_config].each do |strategy| + config.tenant_strategy = strategy + expect(config.tenant_strategy).to eq(strategy) + end + end + + it 'rejects invalid strategies' do + expect { config.tenant_strategy = :invalid }.to raise_error(ArgumentError, /invalid/) + end + end + + describe '#tenants_provider=' do + it 'accepts a callable' do + provider = -> { %w[tenant1 tenant2] } + config.tenants_provider = provider + expect(config.tenants_provider).to eq(provider) + end + end + + describe '#environmentify_strategy=' do + it 'accepts nil, :prepend, :append' do + [nil, :prepend, :append].each do |strategy| + config.environmentify_strategy = strategy + expect(config.environmentify_strategy).to eq(strategy) + end + end + + it 'accepts a callable' do + callable = ->(tenant) { "test_#{tenant}" } + config.environmentify_strategy = callable + expect(config.environmentify_strategy).to eq(callable) + end + + it 'rejects invalid symbols' do + expect { config.environmentify_strategy = :invalid }.to raise_error(ArgumentError) + end + end + + describe '#parallel_strategy=' do + it 'accepts :auto, :threads, :processes' do + %i[auto threads processes].each do |strategy| + config.parallel_strategy = strategy + expect(config.parallel_strategy).to eq(strategy) + end + end + + it 'rejects invalid strategies' do + expect { config.parallel_strategy = :invalid }.to raise_error(ArgumentError) + end + end + + describe '#configure_postgres' do + it 'yields a PostgreSQLConfig' do + config.configure_postgres do |pg| + expect(pg).to be_a(Apartment::Configs::PostgreSQLConfig) + pg.persistent_schemas = %w[ext public] + end + expect(config.postgres_config.persistent_schemas).to eq(%w[ext public]) + end + end + + describe '#configure_mysql' do + it 'yields a MySQLConfig' do + config.configure_mysql do |mysql| + expect(mysql).to be_a(Apartment::Configs::MySQLConfig) + end + expect(config.mysql_config).to be_a(Apartment::Configs::MySQLConfig) + end + end + + describe '#validate!' do + before do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + it 'passes with valid config' do + expect { config.validate! }.not_to raise_error + end + + it 'fails without tenant_strategy' do + config.instance_variable_set(:@tenant_strategy, nil) + expect { config.validate! }.to raise_error(Apartment::ConfigurationError, /tenant_strategy/) + end + + it 'fails without tenants_provider' do + config.tenants_provider = nil + expect { config.validate! }.to raise_error(Apartment::ConfigurationError, /tenants_provider/) + end + + it 'fails if tenants_provider is not callable' do + config.tenants_provider = %w[tenant1 tenant2] + expect { config.validate! }.to raise_error(Apartment::ConfigurationError, /callable/) + end + + it 'fails if both postgres and mysql configured' do + config.configure_postgres { |pg| pg.persistent_schemas = [] } + config.configure_mysql { |_mysql| } + expect { config.validate! }.to raise_error(Apartment::ConfigurationError, /both/) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/config_spec.rb` +Expected: FAIL — `uninitialized constant Apartment::Config` + +- [ ] **Step 3: Write implementation** + +```ruby +# lib/apartment/config.rb +# frozen_string_literal: true + +module Apartment + class Config + # Required + attr_reader :tenant_strategy + + # Required — must be callable + attr_accessor :tenants_provider + + # Tenant defaults + attr_accessor :default_tenant, :excluded_models, :persistent_schemas + + # Pool management + attr_accessor :tenant_pool_size, :pool_idle_timeout, :max_total_connections + + # Lifecycle + attr_accessor :seed_after_create, :seed_data_file + + # Parallel migrations + attr_accessor :parallel_migration_threads + attr_reader :parallel_strategy + + # Environment + attr_reader :environmentify_strategy + + # Elevator + attr_accessor :elevator, :elevator_options + + # Error handling + attr_accessor :tenant_not_found_handler + + # Logging + attr_accessor :active_record_log + + # Database-specific + attr_reader :postgres_config, :mysql_config + + TENANT_STRATEGIES = %i[schema database_name shard database_config].freeze + ENVIRONMENTIFY_STRATEGIES = [nil, :prepend, :append].freeze + PARALLEL_STRATEGIES = %i[auto threads processes].freeze + + private_constant :TENANT_STRATEGIES, :ENVIRONMENTIFY_STRATEGIES, :PARALLEL_STRATEGIES + + def initialize + @tenant_strategy = nil + @tenants_provider = nil + @default_tenant = nil + @excluded_models = [] + @persistent_schemas = [] + @tenant_pool_size = 5 + @pool_idle_timeout = 300 + @max_total_connections = nil + @seed_after_create = false + @seed_data_file = nil + @parallel_migration_threads = 0 + @parallel_strategy = :auto + @environmentify_strategy = nil + @elevator = nil + @elevator_options = {} + @tenant_not_found_handler = nil + @active_record_log = false + @postgres_config = nil + @mysql_config = nil + end + + def tenant_strategy=(value) + unless TENANT_STRATEGIES.include?(value) + raise ArgumentError, "Option #{value} not valid for `tenant_strategy`. Use one of #{TENANT_STRATEGIES.join(', ')}" + end + + @tenant_strategy = value + end + + def environmentify_strategy=(value) + unless value.respond_to?(:call) || ENVIRONMENTIFY_STRATEGIES.include?(value) + raise ArgumentError, "Option #{value} not valid for `environmentify_strategy`. Use one of #{ENVIRONMENTIFY_STRATEGIES.join(', ')} or a callable" + end + + @environmentify_strategy = value + end + + def parallel_strategy=(value) + unless PARALLEL_STRATEGIES.include?(value) + raise ArgumentError, "Option #{value} not valid for `parallel_strategy`. Use one of #{PARALLEL_STRATEGIES.join(', ')}" + end + + @parallel_strategy = value + end + + def configure_postgres + @postgres_config = Configs::PostgreSQLConfig.new + yield(@postgres_config) + end + + def configure_mysql + @mysql_config = Configs::MySQLConfig.new + yield(@mysql_config) + end + + def validate! + raise ConfigurationError, 'tenant_strategy is required' if @tenant_strategy.nil? + + unless @tenants_provider.respond_to?(:call) + raise ConfigurationError, 'tenants_provider must be a callable (e.g., -> { Tenant.pluck(:name) })' + end + + if @postgres_config && @mysql_config + raise ConfigurationError, 'Cannot configure both Postgres and MySQL at the same time' + end + end + end +end +``` + +```ruby +# lib/apartment/configs/postgresql_config.rb +# frozen_string_literal: true + +module Apartment + module Configs + class PostgreSQLConfig + attr_accessor :persistent_schemas, :enforce_search_path_reset, :include_schemas_in_dump + + def initialize + @persistent_schemas = [] + @enforce_search_path_reset = false + @include_schemas_in_dump = [] + end + end + end +end +``` + +```ruby +# lib/apartment/configs/mysql_config.rb +# frozen_string_literal: true + +module Apartment + module Configs + class MySQLConfig + def initialize + # MySQL-specific options — minimal for now + end + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/config_spec.rb` +Expected: All examples pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/config.rb lib/apartment/configs/ spec/unit/config_spec.rb +git commit -m "Add v4 configuration system with validation" +``` + +--- + +## Task 5: Apartment module and configure DSL + +**Files:** +- Create: `lib/apartment.rb` +- Create: `spec/spec_helper.rb` + +- [ ] **Step 1: Write the failing test** + +Add to `spec/unit/config_spec.rb`: + +```ruby +RSpec.describe Apartment do + after { Apartment.clear_config } + + describe '.configure' do + it 'yields a Config object' do + Apartment.configure do |config| + expect(config).to be_a(Apartment::Config) + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + end + + it 'stores the config' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'public' + end + expect(Apartment.config.default_tenant).to eq('public') + end + + it 'validates on configure' do + expect { + Apartment.configure do |config| + # Missing tenant_strategy and tenants_provider + end + }.to raise_error(Apartment::ConfigurationError) + end + end + + describe '.clear_config' do + it 'resets config to nil' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + Apartment.clear_config + expect(Apartment.config).to be_nil + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/config_spec.rb` +Expected: FAIL — `undefined method 'configure' for Apartment` + +- [ ] **Step 3: Write implementation** + +```ruby +# lib/apartment.rb +# frozen_string_literal: true + +require 'active_support' +require 'active_record' +require 'concurrent' + +require 'zeitwerk' +loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false) +loader.inflector.inflect( + 'mysql_config' => 'MySQLConfig', + 'postgresql_config' => 'PostgreSQLConfig' +) +loader.setup + +module Apartment + class << self + # @return [Apartment::Config, nil] + attr_reader :config + + # @return [Apartment::PoolManager, nil] + attr_reader :pool_manager + + def configure + @config = Config.new + yield(@config) + @config.validate! + @pool_manager = PoolManager.new + end + + def clear_config + @pool_manager&.clear + @config = nil + @pool_manager = nil + end + end +end +``` + +- [ ] **Step 4: Create spec_helper.rb** + +```ruby +# spec/spec_helper.rb +# frozen_string_literal: true + +require 'bundler/setup' +require 'apartment' + +RSpec.configure do |config| + config.order = :random + config.filter_run_when_matching :focus + + config.after(:each) do + Apartment::Current.reset + Apartment.clear_config + end +end +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/` +Expected: All examples pass + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment.rb spec/spec_helper.rb spec/unit/config_spec.rb +git commit -m "Add Apartment module with configure DSL" +``` + +--- + +## Task 6: Apartment::PoolManager + +**Files:** +- Create: `lib/apartment/pool_manager.rb` +- Create: `spec/unit/pool_manager_spec.rb` + +- [ ] **Step 1: Write the failing test** + +```ruby +# spec/unit/pool_manager_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Apartment::PoolManager do + subject(:manager) { described_class.new } + + describe '#fetch_or_create' do + it 'creates and caches a new entry' do + result = manager.fetch_or_create('tenant_a') { 'pool_a' } + expect(result).to eq('pool_a') + end + + it 'returns cached entry on subsequent calls' do + call_count = 0 + 2.times do + manager.fetch_or_create('tenant_a') { call_count += 1; "pool_#{call_count}" } + end + expect(manager.fetch_or_create('tenant_a') { 'new' }).to eq('pool_1') + end + + it 'updates last_accessed timestamp' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + stats = manager.stats_for('tenant_a') + expect(stats[:last_accessed]).to be_within(1).of(Time.now) + end + end + + describe '#remove' do + it 'removes a tracked pool' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + manager.remove('tenant_a') + expect(manager.tracked?('tenant_a')).to be false + end + + it 'returns the removed value' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + expect(manager.remove('tenant_a')).to eq('pool_a') + end + + it 'returns nil for unknown tenants' do + expect(manager.remove('unknown')).to be_nil + end + end + + describe '#idle_tenants' do + it 'returns tenants idle beyond threshold' do + manager.fetch_or_create('old') { 'pool_old' } + # Backdate the timestamp + manager.instance_variable_get(:@timestamps)['old'] = Time.now - 600 + manager.fetch_or_create('recent') { 'pool_recent' } + + idle = manager.idle_tenants(timeout: 300) + expect(idle).to include('old') + expect(idle).not_to include('recent') + end + end + + describe '#lru_tenants' do + it 'returns tenants sorted by least recently accessed' do + manager.fetch_or_create('a') { 'pool_a' } + manager.instance_variable_get(:@timestamps)['a'] = Time.now - 300 + manager.fetch_or_create('b') { 'pool_b' } + manager.instance_variable_get(:@timestamps)['b'] = Time.now - 200 + manager.fetch_or_create('c') { 'pool_c' } + + lru = manager.lru_tenants(count: 2) + expect(lru).to eq(%w[a b]) + end + end + + describe '#stats' do + it 'returns pool count and tenant list' do + manager.fetch_or_create('a') { 'pool_a' } + manager.fetch_or_create('b') { 'pool_b' } + + stats = manager.stats + expect(stats[:total_pools]).to eq(2) + expect(stats[:tenants]).to contain_exactly('a', 'b') + end + end + + describe '#clear' do + it 'removes all tracked pools' do + manager.fetch_or_create('a') { 'pool_a' } + manager.fetch_or_create('b') { 'pool_b' } + manager.clear + expect(manager.stats[:total_pools]).to eq(0) + end + end + + describe 'thread safety' do + it 'handles concurrent fetch_or_create without duplicates' do + results = Concurrent::Array.new + threads = 10.times.map do + Thread.new { results << manager.fetch_or_create('shared') { SecureRandom.hex } } + end + threads.each(&:join) + + expect(results.uniq.size).to eq(1) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/pool_manager_spec.rb` +Expected: FAIL — `uninitialized constant Apartment::PoolManager` + +- [ ] **Step 3: Write implementation** + +```ruby +# lib/apartment/pool_manager.rb +# frozen_string_literal: true + +require 'concurrent' + +module Apartment + class PoolManager + def initialize + @pools = Concurrent::Map.new + @timestamps = Concurrent::Map.new + end + + # Fetch an existing pool or create one via the block. + # Updates last_accessed timestamp on every access. + def fetch_or_create(tenant_key) + touch(tenant_key) + @pools.compute_if_absent(tenant_key) { yield } + end + + def get(tenant_key) + touch(tenant_key) if @pools.key?(tenant_key) + @pools[tenant_key] + end + + def remove(tenant_key) + @timestamps.delete(tenant_key) + @pools.delete(tenant_key) + end + + def tracked?(tenant_key) + @pools.key?(tenant_key) + end + + def stats_for(tenant_key) + return nil unless tracked?(tenant_key) + + { last_accessed: @timestamps[tenant_key] } + end + + def idle_tenants(timeout:) + cutoff = Time.now - timeout + @timestamps.each_pair.filter_map { |key, ts| key if ts < cutoff } + end + + def lru_tenants(count:) + @timestamps.each_pair + .sort_by { |_, ts| ts } + .first(count) + .map(&:first) + end + + def stats + { + total_pools: @pools.size, + tenants: @pools.keys, + } + end + + def clear + @pools.clear + @timestamps.clear + end + + private + + def touch(tenant_key) + @timestamps[tenant_key] = Time.now + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/pool_manager_spec.rb` +Expected: All examples pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/pool_manager.rb spec/unit/pool_manager_spec.rb +git commit -m "Add PoolManager with Concurrent::Map and LRU tracking" +``` + +--- + +## Task 7: Apartment::PoolReaper + +**Files:** +- Create: `lib/apartment/pool_reaper.rb` +- Create: `spec/unit/pool_reaper_spec.rb` + +- [ ] **Step 1: Write the failing test** + +```ruby +# spec/unit/pool_reaper_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Apartment::PoolReaper do + let(:pool_manager) { Apartment::PoolManager.new } + let(:disconnect_calls) { Concurrent::Array.new } + + # Provide a disconnect callback so we can track evictions without real DB pools + let(:on_evict) { ->(tenant, _pool) { disconnect_calls << tenant } } + + after { described_class.stop } + + describe '.start / .stop' do + it 'can start and stop without error' do + described_class.start( + pool_manager: pool_manager, + interval: 0.1, + idle_timeout: 0.2, + on_evict: on_evict + ) + expect(described_class).to be_running + described_class.stop + expect(described_class).not_to be_running + end + end + + describe 'idle eviction' do + it 'evicts pools idle beyond timeout' do + pool_manager.fetch_or_create('stale') { 'pool_stale' } + # Backdate timestamp + pool_manager.instance_variable_get(:@timestamps)['stale'] = Time.now - 10 + + pool_manager.fetch_or_create('fresh') { 'pool_fresh' } + + described_class.start( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + on_evict: on_evict + ) + + sleep 0.2 # Let the reaper run at least once + + expect(disconnect_calls).to include('stale') + expect(pool_manager.tracked?('stale')).to be false + expect(pool_manager.tracked?('fresh')).to be true + end + end + + describe 'max_total eviction' do + it 'evicts LRU pools when over max' do + 3.times do |i| + pool_manager.fetch_or_create("tenant_#{i}") { "pool_#{i}" } + pool_manager.instance_variable_get(:@timestamps)["tenant_#{i}"] = Time.now - (300 - i * 100) + end + + described_class.start( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 999, # Don't idle-evict + max_total: 2, + on_evict: on_evict + ) + + sleep 0.2 + + expect(pool_manager.stats[:total_pools]).to be <= 2 + expect(disconnect_calls).to include('tenant_0') # Oldest + end + end + + describe 'protected tenants' do + it 'never evicts the default tenant' do + pool_manager.fetch_or_create('public') { 'pool_default' } + pool_manager.instance_variable_get(:@timestamps)['public'] = Time.now - 9999 + + described_class.start( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + default_tenant: 'public', + on_evict: on_evict + ) + + sleep 0.2 + + expect(pool_manager.tracked?('public')).to be true + expect(disconnect_calls).not_to include('public') + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb` +Expected: FAIL — `uninitialized constant Apartment::PoolReaper` + +- [ ] **Step 3: Write implementation** + +```ruby +# lib/apartment/pool_reaper.rb +# frozen_string_literal: true + +require 'concurrent' + +module Apartment + class PoolReaper + class << self + def start(pool_manager:, interval:, idle_timeout:, max_total: nil, default_tenant: nil, on_evict: nil) + stop if running? + + @pool_manager = pool_manager + @idle_timeout = idle_timeout + @max_total = max_total + @default_tenant = default_tenant + @on_evict = on_evict + + @timer = Concurrent::TimerTask.new(execution_interval: interval) { reap } + @timer.execute + end + + def stop + @timer&.shutdown + @timer = nil + end + + def running? + @timer&.running? || false + end + + private + + def reap + evict_idle + evict_lru if @max_total + rescue => e + # Don't let reaper exceptions kill the timer + warn "[Apartment::PoolReaper] Error during eviction: #{e.message}" + end + + def evict_idle + @pool_manager.idle_tenants(timeout: @idle_timeout).each do |tenant| + next if tenant == @default_tenant + + pool = @pool_manager.remove(tenant) + @on_evict&.call(tenant, pool) + end + end + + def evict_lru + excess = @pool_manager.stats[:total_pools] - @max_total + return if excess <= 0 + + candidates = @pool_manager.lru_tenants(count: excess + 1) # +1 in case default is in list + evicted = 0 + candidates.each do |tenant| + break if evicted >= excess + next if tenant == @default_tenant + + pool = @pool_manager.remove(tenant) + @on_evict&.call(tenant, pool) + evicted += 1 + end + end + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb` +Expected: All examples pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/pool_reaper.rb spec/unit/pool_reaper_spec.rb +git commit -m "Add PoolReaper with idle eviction and LRU cleanup" +``` + +--- + +## Task 8: AS::Notifications instrumentation + +**Files:** +- Create: `lib/apartment/instrumentation.rb` +- Create: `spec/unit/instrumentation_spec.rb` + +- [ ] **Step 1: Write the failing test** + +```ruby +# spec/unit/instrumentation_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Apartment::Instrumentation do + describe '.instrument' do + it 'publishes switch.apartment events' do + events = [] + ActiveSupport::Notifications.subscribe('switch.apartment') { |event| events << event } + + described_class.instrument(:switch, tenant: 'acme', previous_tenant: 'public') + + expect(events.size).to eq(1) + expect(events.first.payload).to include(tenant: 'acme', previous_tenant: 'public') + ensure + ActiveSupport::Notifications.unsubscribe('switch.apartment') + end + + it 'publishes create.apartment events' do + events = [] + ActiveSupport::Notifications.subscribe('create.apartment') { |event| events << event } + + described_class.instrument(:create, tenant: 'new_tenant') + + expect(events.size).to eq(1) + expect(events.first.payload[:tenant]).to eq('new_tenant') + ensure + ActiveSupport::Notifications.unsubscribe('create.apartment') + end + + it 'publishes evict.apartment events' do + events = [] + ActiveSupport::Notifications.subscribe('evict.apartment') { |event| events << event } + + described_class.instrument(:evict, tenant: 'old', reason: :idle) + + expect(events.first.payload).to include(tenant: 'old', reason: :idle) + ensure + ActiveSupport::Notifications.unsubscribe('evict.apartment') + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/instrumentation_spec.rb` +Expected: FAIL — `uninitialized constant Apartment::Instrumentation` + +- [ ] **Step 3: Write implementation** + +```ruby +# lib/apartment/instrumentation.rb +# frozen_string_literal: true + +require 'active_support/notifications' + +module Apartment + module Instrumentation + EVENTS = %i[switch create drop evict pool_stats].freeze + + def self.instrument(event, payload = {}, &block) + event_name = "#{event}.apartment" + if block + ActiveSupport::Notifications.instrument(event_name, payload, &block) + else + ActiveSupport::Notifications.instrument(event_name, payload) { } + end + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/instrumentation_spec.rb` +Expected: All examples pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/instrumentation.rb spec/unit/instrumentation_spec.rb +git commit -m "Add AS::Notifications instrumentation for apartment events" +``` + +--- + +## Task 9: Wire PoolReaper eviction to instrumentation + +**Files:** +- Modify: `lib/apartment/pool_reaper.rb` + +- [ ] **Step 1: Add eviction instrumentation test** + +Add to `spec/unit/pool_reaper_spec.rb`: + +```ruby +describe 'instrumentation' do + it 'emits evict.apartment events on eviction' do + events = Concurrent::Array.new + ActiveSupport::Notifications.subscribe('evict.apartment') { |event| events << event } + + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = Time.now - 10 + + described_class.start( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + on_evict: on_evict + ) + + sleep 0.2 + + expect(events.any? { |e| e.payload[:tenant] == 'stale' }).to be true + ensure + ActiveSupport::Notifications.unsubscribe('evict.apartment') + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb` +Expected: FAIL on the new instrumentation test + +- [ ] **Step 3: Update PoolReaper to emit events** + +In `lib/apartment/pool_reaper.rb`, update the `evict_idle` and `evict_lru` methods to call `Instrumentation.instrument`: + +```ruby +def evict_idle + @pool_manager.idle_tenants(timeout: @idle_timeout).each do |tenant| + next if tenant == @default_tenant + + pool = @pool_manager.remove(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :idle) + @on_evict&.call(tenant, pool) + end +end + +def evict_lru + excess = @pool_manager.stats[:total_pools] - @max_total + return if excess <= 0 + + candidates = @pool_manager.lru_tenants(count: excess + 1) + evicted = 0 + candidates.each do |tenant| + break if evicted >= excess + next if tenant == @default_tenant + + pool = @pool_manager.remove(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :lru) + @on_evict&.call(tenant, pool) + evicted += 1 + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb` +Expected: All examples pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/pool_reaper.rb spec/unit/pool_reaper_spec.rb +git commit -m "Wire PoolReaper eviction to AS::Notifications instrumentation" +``` + +--- + +## Task 10: Full Phase 1 integration test + +**Files:** +- Create: `spec/unit/phase1_integration_spec.rb` + +- [ ] **Step 1: Write the integration test** + +```ruby +# spec/unit/phase1_integration_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Phase 1 integration' do + after { Apartment.clear_config } + + it 'configure -> pool_manager -> current -> reaper work together' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { %w[acme globex] } + config.default_tenant = 'public' + config.tenant_pool_size = 5 + config.pool_idle_timeout = 1 + end + + expect(Apartment.config.tenant_strategy).to eq(:schema) + expect(Apartment.pool_manager).to be_a(Apartment::PoolManager) + + # Simulate tenant switching via Current + Apartment::Current.tenant = 'acme' + expect(Apartment::Current.tenant).to eq('acme') + + # Pool manager tracks tenant pools + pool = Apartment.pool_manager.fetch_or_create('acme') { 'fake_pool' } + expect(pool).to eq('fake_pool') + + # Stats work + stats = Apartment.pool_manager.stats + expect(stats[:total_pools]).to eq(1) + expect(stats[:tenants]).to eq(['acme']) + + # Current resets cleanly + Apartment::Current.reset + expect(Apartment::Current.tenant).to be_nil + end + + it 'raises correct errors for invalid config' do + expect { + Apartment.configure do |config| + config.tenant_strategy = :schema + # Missing tenants_provider + end + }.to raise_error(Apartment::ConfigurationError) + end + + it 'raises TenantNotFound with tenant name' do + error = Apartment::TenantNotFound.new('missing') + expect(error.message).to eq("Tenant 'missing' not found") + expect(error).to be_a(Apartment::ApartmentError) + end +end +``` + +- [ ] **Step 2: Run all Phase 1 specs** + +Run: `bundle exec rspec spec/unit/` +Expected: All examples pass + +- [ ] **Step 3: Commit** + +```bash +git add spec/unit/phase1_integration_spec.rb +git commit -m "Add Phase 1 integration test" +``` + +--- + +## Completion Checklist + +After all tasks are done: + +- [ ] All specs pass: `bundle exec rspec spec/unit/` +- [ ] No Zeitwerk eager load errors: `bundle exec ruby -e "require 'apartment'; Zeitwerk::Loader.eager_load_all"` +- [ ] Gem builds: `gem build ros-apartment.gemspec` +- [ ] All files committed, branch ready for PR diff --git a/docs/plans/apartment-v4/phase-2-adapters.md b/docs/plans/apartment-v4/phase-2-adapters.md new file mode 100644 index 00000000..bca6d18f --- /dev/null +++ b/docs/plans/apartment-v4/phase-2-adapters.md @@ -0,0 +1,1086 @@ +# Phase 2: Adapters & Tenant API — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the database engine — adapters that create/drop/switch tenants, the public `Apartment::Tenant` API, and ActiveRecord connection handling patches. End state: `Apartment::Tenant.switch("acme") { User.count }` works end-to-end against real PostgreSQL and MySQL databases. + +**Architecture:** v4 eliminates v3's adapter-per-thread pattern. Instead, database differences are expressed as configuration strategies (how to build a tenant-specific connection config). The `Tenant` module sets `Current.tenant`, and `ConnectionHandling` patches resolve the right pool. Adapters handle lifecycle operations (create/drop schema or database) but not switching — switching is a pool lookup. + +**Tech Stack:** Ruby 3.3+, ActiveRecord 7.2+, PostgreSQL 14+, MySQL 8.4+, SQLite3, RSpec + +**Spec:** [`docs/designs/apartment-v4.md`](../../designs/apartment-v4.md) + +**Depends on:** Phase 1 (Config, Current, PoolManager, PoolReaper, Errors, Instrumentation) + +--- + +## Architectural Overview + +### How v4 switching works (vs v3) + +``` +v3: Tenant.switch("acme") + → adapter.switch!("acme") + → SET search_path TO acme,public (PostgreSQL) + → or USE acme_db (MySQL) + → Thread.current[:apartment_adapter].current = "acme" + +v4: Tenant.switch("acme") { ... } + → Current.tenant = "acme" + → ActiveRecord queries resolve connection_pool via ConnectionHandling patch + → Pool lookup in PoolManager (cached Concurrent::Map) + → Pool created lazily with tenant-specific config if absent + → No SQL switching commands at all +``` + +### Key components and their responsibilities + +``` +Apartment::Tenant Public API (switch, current, reset, create, drop, etc.) + | + v +Apartment::Current Fiber-safe tenant context (from Phase 1) + | + v +Apartment::Patches:: Prepended on AR::Base — intercepts connection_pool +ConnectionHandling to return tenant-specific pool + | + v +Apartment::PoolManager Caches pools by tenant key (from Phase 1) + | + v +Apartment::Adapters:: Lifecycle operations: create/drop schema or database +AbstractAdapter Resolves connection config per strategy + | + +-- PostgreSQLSchemaAdapter CREATE SCHEMA / DROP SCHEMA + +-- PostgreSQLDatabaseAdapter CREATE DATABASE / DROP DATABASE (PostgreSQL) + +-- MySQL2Adapter CREATE DATABASE / DROP DATABASE (MySQL) + +-- TrilogyAdapter Same as MySQL2, different driver + +-- SQLite3Adapter File creation/deletion +``` + +--- + +## File Map + +### New files (create) + +| File | Responsibility | +|------|---------------| +| `lib/apartment/tenant.rb` | Public API module (replaces v3 tenant.rb) | +| `lib/apartment/adapters/abstract_adapter.rb` | Base adapter with lifecycle, callbacks, excluded models | +| `lib/apartment/adapters/postgresql_schema_adapter.rb` | Schema-per-tenant: CREATE/DROP SCHEMA, resolve_connection_config | +| `lib/apartment/adapters/postgresql_database_adapter.rb` | Database-per-tenant on PostgreSQL | +| `lib/apartment/adapters/mysql2_adapter.rb` | Database-per-tenant on MySQL (mysql2 driver) | +| `lib/apartment/adapters/trilogy_adapter.rb` | Database-per-tenant on MySQL (trilogy driver) | +| `lib/apartment/adapters/sqlite3_adapter.rb` | File-per-tenant | +| `lib/apartment/patches/connection_handling.rb` | AR::Base prepend for tenant-aware pool resolution | +| `spec/unit/tenant_spec.rb` | Public API tests (mocked adapters) | +| `spec/unit/adapters/abstract_adapter_spec.rb` | Adapter contract tests | +| `spec/unit/adapters/postgresql_schema_adapter_spec.rb` | PostgreSQL schema tests | +| `spec/unit/adapters/postgresql_database_adapter_spec.rb` | PostgreSQL database tests | +| `spec/unit/adapters/mysql2_adapter_spec.rb` | MySQL tests | +| `spec/unit/patches/connection_handling_spec.rb` | AR patching tests | +| `spec/integration/tenant_switching_spec.rb` | End-to-end switching with real DB | +| `spec/integration/tenant_lifecycle_spec.rb` | Create/drop with real DB | +| `spec/integration/excluded_models_spec.rb` | Excluded model isolation | + +### Modified files + +| File | Change | +|------|--------| +| `lib/apartment.rb` | Add adapter accessor, adapter factory, update Zeitwerk ignores | +| `lib/apartment/config.rb` | Add `adapter` reader (lazily resolved from strategy + database.yml) | +| `Gemfile` | Add database gems as development dependencies | + +### Removed v3 files (replaced) + +| File | Replacement | +|------|-------------| +| `lib/apartment/tenant.rb` | New v4 tenant.rb | +| `lib/apartment/adapters/abstract_adapter.rb` | New v4 abstract_adapter.rb | +| `lib/apartment/adapters/postgresql_adapter.rb` | Split into postgresql_schema_adapter.rb + postgresql_database_adapter.rb | +| `lib/apartment/adapters/mysql2_adapter.rb` | New v4 mysql2_adapter.rb | +| `lib/apartment/adapters/trilogy_adapter.rb` | New v4 trilogy_adapter.rb | +| `lib/apartment/adapters/sqlite3_adapter.rb` | New v4 sqlite3_adapter.rb | +| `lib/apartment/adapters/abstract_jdbc_adapter.rb` | Dropped (JDBC not supported in v4) | +| `lib/apartment/adapters/jdbc_postgresql_adapter.rb` | Dropped | +| `lib/apartment/adapters/jdbc_mysql_adapter.rb` | Dropped | +| `lib/apartment/adapters/postgis_adapter.rb` | Dropped (use PostgreSQLSchemaAdapter with PostGIS) | +| `lib/apartment/model.rb` | Replaced by excluded model handling in abstract_adapter | +| `lib/apartment/active_record/` | Replaced by patches/connection_handling.rb | + +--- + +## Task 1: Apartment::Tenant public API + +**Files:** +- Replace: `lib/apartment/tenant.rb` +- Create: `spec/unit/tenant_spec.rb` +- Modify: `lib/apartment.rb` (update Zeitwerk ignores, add adapter accessor) + +This task builds the public API with stubbed adapter delegation. No real database operations yet — that comes in later tasks. + +### Implementation + +`lib/apartment/tenant.rb`: + +```ruby +# frozen_string_literal: true + +module Apartment + module Tenant + class << self + # Switch to a tenant for the duration of the block. + # Guaranteed cleanup via ensure — tenant context is always restored. + def switch(tenant) + raise ArgumentError, 'Apartment::Tenant.switch requires a block' unless block_given? + + previous = Current.tenant + Current.tenant = tenant + Current.previous_tenant = previous + yield + ensure + Current.tenant = previous + Current.previous_tenant = nil + end + + # Direct switch without block. Discouraged — prefer switch with block. + def switch!(tenant) + Current.previous_tenant = Current.tenant + Current.tenant = tenant + end + + # Current tenant name. + def current + Current.tenant || Apartment.config&.default_tenant + end + + # Reset to default tenant. + def reset + switch!(Apartment.config&.default_tenant) + end + + # Initialize: process excluded models, set up default tenant. + def init + adapter.process_excluded_models + end + + # Delegate lifecycle operations to the adapter. + def create(tenant) + adapter.create(tenant) + end + + def drop(tenant) + adapter.drop(tenant) + end + + def migrate(tenant, version = nil) + adapter.migrate(tenant, version) + end + + def seed(tenant) + adapter.seed(tenant) + end + + # Pool stats delegated to pool_manager. + def pool_stats + Apartment.pool_manager&.stats || {} + end + + private + + def adapter + Apartment.adapter + end + end + end +end +``` + +### Tests + +`spec/unit/tenant_spec.rb` should test: +- `switch` sets/restores Current.tenant and Current.previous_tenant +- `switch` restores tenant on exception +- `switch` requires a block (ArgumentError) +- `switch!` sets tenant without block +- `current` returns Current.tenant or default_tenant +- `reset` sets tenant to default +- `create`, `drop`, `migrate`, `seed` delegate to adapter + +Use mocked adapter for lifecycle delegation tests. + +### apartment.rb updates + +- Remove `tenant.rb` from Zeitwerk ignore list (it's being replaced) +- Add `adapter` accessor and factory method +- Remove v3 adapter files from ignore list, replace with new adapter directory handling + +--- + +## Task 2: Patches::ConnectionHandling + +**Files:** +- Create: `lib/apartment/patches/connection_handling.rb` +- Create: `spec/unit/patches/connection_handling_spec.rb` + +This is the most architecturally sensitive component — it patches ActiveRecord::Base to make pool lookups tenant-aware. + +### Implementation + +`lib/apartment/patches/connection_handling.rb`: + +```ruby +# frozen_string_literal: true + +module Apartment + module Patches + module ConnectionHandling + # Override connection_pool to return a tenant-specific pool. + # When Current.tenant is set, looks up (or lazily creates) a pool + # with tenant-specific connection config. + def connection_pool + tenant = Apartment::Current.tenant + default = Apartment.config&.default_tenant + + # No tenant context or default tenant — use Rails' normal behavior + return super if tenant.nil? || tenant == default + + pool_key = "#{connection_specification_name}[#{tenant}]" + + Apartment.pool_manager.fetch_or_create(pool_key) do + # Ask the adapter to resolve the connection config for this tenant + config = Apartment.adapter.resolve_connection_config(tenant) + + # Establish a new connection pool with tenant-specific config + # This registers the pool with ActiveRecord's ConnectionHandler + resolver = ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolver.new({}) + # ... (Rails version-specific pool creation) + # + # The exact mechanism varies by Rails version (7.2 vs 8.0 vs 8.1). + # Implementation will use establish_connection with a tenant-qualified + # config name to create the pool within AR's handler. + end + end + end + end +end +``` + +**Critical implementation notes:** +- Must work across Rails 7.2, 8.0, and 8.1 +- ActiveRecord's pool creation API changed between versions — need version gates or duck-typing +- The pool must be registered with AR's ConnectionHandler so `database_cleaner`, `strong_migrations`, etc. work +- `connection_specification_name` must be overridden to include tenant for proper pool keying + +### Tests + +Test with a real SQLite3 database (lightweight, no external service needed): +- Default tenant returns super's pool +- Tenant set returns tenant-specific pool +- Same tenant returns same pool (cached) +- Different tenants return different pools +- Pool is registered with AR's ConnectionHandler + +--- + +## Task 3: AbstractAdapter + +**Files:** +- Create: `lib/apartment/adapters/abstract_adapter.rb` +- Create: `spec/unit/adapters/abstract_adapter_spec.rb` + +### Implementation + +```ruby +# frozen_string_literal: true + +module Apartment + module Adapters + class AbstractAdapter + include ActiveSupport::Callbacks + define_callbacks :create, :switch + + attr_reader :config + + def initialize(config) + @config = config + end + + # Resolve a tenant-specific connection config hash. + # Subclasses override to set strategy-specific keys. + def resolve_connection_config(tenant) + raise NotImplementedError + end + + # Create a new tenant (schema or database). + def create(tenant) + run_callbacks :create do + create_tenant(tenant) + Instrumentation.instrument(:create, tenant: tenant) + end + end + + # Drop a tenant. + def drop(tenant) + drop_tenant(tenant) + # Remove cached pool + pool_key = "ActiveRecord::Base[#{tenant}]" + pool = Apartment.pool_manager.remove(pool_key) + pool&.disconnect! if pool.respond_to?(:disconnect!) + Instrumentation.instrument(:drop, tenant: tenant) + end + + # Run migrations for a tenant. + def migrate(tenant, version = nil) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection_pool.migration_context.migrate(version) + end + end + + # Run seeds for a tenant. + def seed(tenant) + Apartment::Tenant.switch(tenant) do + seed_file = Apartment.config.seed_data_file + load(seed_file) if seed_file && File.exist?(seed_file) + end + end + + # Process excluded models — establish separate connections pinned to default tenant. + def process_excluded_models + default_config = resolve_connection_config( + Apartment.config.default_tenant + ) + + Apartment.config.excluded_models.each do |model_name| + klass = model_name.constantize + klass.establish_connection(default_config) + end + end + + # Environmentify a tenant name based on config. + def environmentify(tenant) + case Apartment.config.environmentify_strategy + when :prepend + "#{Rails.env}_#{tenant}" + when :append + "#{tenant}_#{Rails.env}" + when nil + tenant.to_s + else + # Callable + Apartment.config.environmentify_strategy.call(tenant) + end + end + + # Default tenant from config. + def default_tenant + Apartment.config.default_tenant + end + + protected + + def create_tenant(tenant) + raise NotImplementedError + end + + def drop_tenant(tenant) + raise NotImplementedError + end + end + end +end +``` + +### Tests + +- `resolve_connection_config` raises NotImplementedError (abstract) +- `create` runs callbacks and instruments +- `drop` removes pool from PoolManager and instruments +- `migrate` switches tenant and runs migrations (mocked) +- `seed` switches tenant and loads seed file (mocked) +- `process_excluded_models` establishes connections for each model +- `environmentify` handles :prepend, :append, nil, and callable + +--- + +## Task 4: PostgreSQLSchemaAdapter + +**Files:** +- Create: `lib/apartment/adapters/postgresql_schema_adapter.rb` +- Create: `spec/unit/adapters/postgresql_schema_adapter_spec.rb` + +### Implementation + +```ruby +# frozen_string_literal: true + +module Apartment + module Adapters + class PostgreSQLSchemaAdapter < AbstractAdapter + def resolve_connection_config(tenant) + pg_config = Apartment.config.postgres_config + persistent = pg_config&.persistent_schemas || [] + search_path = [tenant, *persistent].join(",") + + base_config.merge("schema_search_path" => search_path) + end + + protected + + def create_tenant(tenant) + # Use a connection from the default pool to create the schema + ActiveRecord::Base.connection.execute( + "CREATE SCHEMA #{ActiveRecord::Base.connection.quote_table_name(tenant)}" + ) + end + + def drop_tenant(tenant) + ActiveRecord::Base.connection.execute( + "DROP SCHEMA #{ActiveRecord::Base.connection.quote_table_name(tenant)} CASCADE" + ) + end + + private + + def base_config + Apartment.config.connection_db_config&.configuration_hash&.stringify_keys || + ActiveRecord::Base.connection_db_config.configuration_hash.stringify_keys + end + end + end +end +``` + +### Tests (require real PostgreSQL) + +- `resolve_connection_config` returns config with `schema_search_path` +- `resolve_connection_config` includes persistent_schemas +- `create_tenant` executes `CREATE SCHEMA` +- `drop_tenant` executes `DROP SCHEMA CASCADE` +- Full lifecycle: create → switch → verify isolation → drop + +--- + +## Task 5: PostgreSQLDatabaseAdapter + +**Files:** +- Create: `lib/apartment/adapters/postgresql_database_adapter.rb` +- Create: `spec/unit/adapters/postgresql_database_adapter_spec.rb` + +### Implementation + +```ruby +# frozen_string_literal: true + +module Apartment + module Adapters + class PostgreSQLDatabaseAdapter < AbstractAdapter + def resolve_connection_config(tenant) + base_config.merge("database" => environmentify(tenant)) + end + + protected + + def create_tenant(tenant) + db_name = environmentify(tenant) + # Connect to template1 or default DB for CREATE DATABASE + ActiveRecord::Base.connection.execute( + "CREATE DATABASE #{ActiveRecord::Base.connection.quote_table_name(db_name)}" + ) + end + + def drop_tenant(tenant) + db_name = environmentify(tenant) + ActiveRecord::Base.connection.execute( + "DROP DATABASE IF EXISTS #{ActiveRecord::Base.connection.quote_table_name(db_name)}" + ) + end + + private + + def base_config + ActiveRecord::Base.connection_db_config.configuration_hash.stringify_keys + end + end + end +end +``` + +### Tests (require real PostgreSQL) + +- `resolve_connection_config` returns config with `database` key +- `create_tenant` executes `CREATE DATABASE` +- `drop_tenant` executes `DROP DATABASE` + +--- + +## Task 6: MySQL2Adapter and TrilogyAdapter + +**Files:** +- Create: `lib/apartment/adapters/mysql2_adapter.rb` +- Create: `lib/apartment/adapters/trilogy_adapter.rb` +- Create: `spec/unit/adapters/mysql2_adapter_spec.rb` + +### Implementation + +`mysql2_adapter.rb`: +```ruby +# frozen_string_literal: true + +module Apartment + module Adapters + class MySQL2Adapter < AbstractAdapter + def resolve_connection_config(tenant) + base_config.merge("database" => environmentify(tenant)) + end + + protected + + def create_tenant(tenant) + db_name = environmentify(tenant) + ActiveRecord::Base.connection.execute( + "CREATE DATABASE #{ActiveRecord::Base.connection.quote_table_name(db_name)}" + ) + end + + def drop_tenant(tenant) + db_name = environmentify(tenant) + ActiveRecord::Base.connection.execute( + "DROP DATABASE IF EXISTS #{ActiveRecord::Base.connection.quote_table_name(db_name)}" + ) + end + + private + + def base_config + ActiveRecord::Base.connection_db_config.configuration_hash.stringify_keys + end + end + end +end +``` + +`trilogy_adapter.rb`: +```ruby +# frozen_string_literal: true + +module Apartment + module Adapters + class TrilogyAdapter < MySQL2Adapter + # Same behavior as MySQL2Adapter — Trilogy is a compatible MySQL driver. + # Exception handling differences (Trilogy::Error vs Mysql2::Error) + # are handled at the connection pool level, not the adapter. + end + end +end +``` + +--- + +## Task 7: SQLite3Adapter + +**Files:** +- Create: `lib/apartment/adapters/sqlite3_adapter.rb` +- Create: `spec/unit/adapters/sqlite3_adapter_spec.rb` + +### Implementation + +```ruby +# frozen_string_literal: true + +module Apartment + module Adapters + class SQLite3Adapter < AbstractAdapter + def resolve_connection_config(tenant) + base_config.merge("database" => database_file(tenant)) + end + + protected + + def create_tenant(tenant) + # SQLite creates the file on first connection — just verify the dir exists + FileUtils.mkdir_p(File.dirname(database_file(tenant))) + end + + def drop_tenant(tenant) + file = database_file(tenant) + File.delete(file) if File.exist?(file) + end + + private + + def database_file(tenant) + db_dir = File.dirname(base_config["database"] || "db/#{tenant}.sqlite3") + File.join(db_dir, "#{environmentify(tenant)}.sqlite3") + end + + def base_config + ActiveRecord::Base.connection_db_config.configuration_hash.stringify_keys + end + end + end +end +``` + +--- + +## Task 8: Adapter factory and apartment.rb wiring + +**Files:** +- Modify: `lib/apartment.rb` +- Modify: `lib/apartment/config.rb` + +### Implementation + +Add to `lib/apartment.rb`: +```ruby +def adapter + @adapter ||= build_adapter +end + +private + +def build_adapter + strategy = config.tenant_strategy + db_adapter = detect_database_adapter + + klass = case strategy + when :schema + require_relative 'apartment/adapters/postgresql_schema_adapter' + Adapters::PostgreSQLSchemaAdapter + when :database_name + case db_adapter + when /postgresql/, /postgis/ + require_relative 'apartment/adapters/postgresql_database_adapter' + Adapters::PostgreSQLDatabaseAdapter + when /mysql2/ + require_relative 'apartment/adapters/mysql2_adapter' + Adapters::MySQL2Adapter + when /trilogy/ + require_relative 'apartment/adapters/trilogy_adapter' + Adapters::TrilogyAdapter + when /sqlite/ + require_relative 'apartment/adapters/sqlite3_adapter' + Adapters::SQLite3Adapter + else + raise AdapterNotFound, "No adapter for database: #{db_adapter}" + end + else + raise AdapterNotFound, "Strategy #{strategy} not yet implemented" + end + + klass.new(ActiveRecord::Base.connection_db_config.configuration_hash) +end + +def detect_database_adapter + ActiveRecord::Base.connection_db_config.adapter +end +``` + +Update `clear_config` to also clear the adapter: +```ruby +def clear_config + PoolReaper.stop + @pool_manager&.clear + @config = nil + @pool_manager = nil + @adapter = nil +end +``` + +### Zeitwerk updates + +Remove v3 adapter files from ignore list (they're being replaced): +- Remove `lib/apartment/adapters` from ignore +- Remove `lib/apartment/tenant.rb` from ignore +- Remove `lib/apartment/model.rb` from ignore +- Remove `lib/apartment/active_record` from ignore +- Delete v3 files that are being replaced +- Keep ignoring: `railtie`, `deprecation`, `log_subscriber`, `console`, `custom_console`, `migrator`, `patches` (v3 patches dir) + +--- + +## Task 9: ConnectionHandling implementation (Rails 7.2/8.0/8.1) + +**Files:** +- Create: `lib/apartment/patches/connection_handling.rb` +- Create: `spec/unit/patches/connection_handling_spec.rb` + +This is the most complex task. The implementation must work across Rails 7.2, 8.0, and 8.1, which have different pool management APIs. + +### Implementation approach + +Use ActiveRecord's public `ConnectionHandler#establish_connection` API (stable across Rails 7.2-8.1). This method accepts `config`, `owner_name:`, `role:`, and `shard:` parameters and returns a connection pool. Key behavior: + +- **Rails 7.2+**: `establish_connection` is lazy — pool is created but no connection established until first query. This aligns perfectly with our lazy pool creation model. +- **`owner_name:`** — We use a tenant-qualified name (e.g., `"apartment_acme"`) to create tenant-specific pools that are tracked by AR's handler. +- **Idempotent**: If called with the same config, returns the existing pool (no duplicate creation). + +```ruby +# frozen_string_literal: true + +module Apartment + module Patches + module ConnectionHandling + def connection_pool + tenant = Apartment::Current.tenant + default = Apartment.config&.default_tenant + + return super if tenant.nil? || tenant == default + return super unless Apartment.pool_manager + + pool_key = tenant.to_s + + Apartment.pool_manager.fetch_or_create(pool_key) do + config = Apartment.adapter.resolve_connection_config(tenant) + + # Use AR's public establish_connection API. + # owner_name creates a separate pool namespace for this tenant. + # Rails 7.2+ lazily connects (no actual DB connection until first query). + handler = ActiveRecord::Base.connection_handler + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + Rails.env, "apartment_#{tenant}", config + ) + + handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: ActiveRecord::Base.current_role, + shard: tenant.to_sym + ) + end + end + end + end +end +``` + +**Why `shard: tenant.to_sym`**: Using the tenant as a shard identifier within AR's handler leverages Rails' native multi-database infrastructure. Each tenant gets its own pool keyed by `(owner_name, role, shard)`. This is the same mechanism Rails uses for `connects_to shards: { ... }`, so it integrates cleanly with existing Rails tooling. + +**Alternative considered**: Using `owner_name: "apartment_#{tenant}"` (unique owner per tenant). This works but creates separate pool managers per tenant in AR's handler, which is heavier than using shards. The shard approach is more aligned with Rails' intent. + +**Note on pool eviction**: When the PoolReaper evicts a tenant pool, it must call `handler.remove_connection_pool(shard: tenant.to_sym)` to deregister from AR's handler, in addition to removing from our PoolManager. + +### Tests (SQLite3 for speed, PostgreSQL for integration) + +- Verify pool resolution for default tenant (returns super) +- Verify pool resolution for active tenant (returns tenant pool) +- Verify pool caching (same pool for same tenant) +- Verify different tenants get different pools +- Verify pool is usable (can execute queries) +- Verify pool is registered with AR's ConnectionHandler +- Verify pool is lazy (no connection until first query, per Rails 7.2+ behavior) + +--- + +## Task 10: Excluded models processing + +**Files:** +- Modify: `lib/apartment/adapters/abstract_adapter.rb` +- Create: `spec/unit/excluded_models_spec.rb` + +### Implementation + +Excluded models get `establish_connection` with the default tenant's config, pinning them to the shared schema/database. Their table names are prefixed with the default schema/database name for PostgreSQL schema strategy. + +```ruby +def process_excluded_models + return if Apartment.config.excluded_models.empty? + + default_config = resolve_connection_config(default_tenant) + + Apartment.config.excluded_models.each do |model_name| + klass = model_name.constantize + + # Establish a separate connection pinned to default tenant + klass.establish_connection(default_config) + + # For PostgreSQL schema strategy, prefix table names + if Apartment.config.tenant_strategy == :schema + table = klass.table_name.split('.').last # Strip existing prefix if any + klass.table_name = "#{default_tenant}.#{table}" + end + end +end +``` + +### Tests + +- Excluded models connect to default tenant +- Excluded model queries work regardless of current tenant +- PostgreSQL schema strategy prefixes table names +- Database strategy does not prefix table names + +--- + +## Task 11: Integration tests with real databases + +**Files:** +- Create: `spec/integration/tenant_switching_spec.rb` +- Create: `spec/integration/tenant_lifecycle_spec.rb` +- Create: `spec/integration/excluded_models_spec.rb` +- Create: `spec/support/database_helper.rb` +- Modify: `Gemfile` (add database gems) + +### Database helper + +```ruby +# spec/support/database_helper.rb +module DatabaseHelper + def self.database_engine + ENV.fetch('DB', 'sqlite3') + end + + def self.postgresql? + database_engine == 'postgresql' + end + + def self.mysql? + database_engine == 'mysql' + end + + def self.sqlite? + database_engine == 'sqlite3' + end +end +``` + +### Integration test: tenant switching + +```ruby +# spec/integration/tenant_switching_spec.rb +RSpec.describe 'Tenant switching', :integration do + before(:all) do + Apartment.configure do |config| + config.tenant_strategy = strategy_for_engine + config.tenants_provider = -> { %w[tenant_a tenant_b] } + config.default_tenant = default_for_engine + end + Apartment::Tenant.create('tenant_a') + Apartment::Tenant.create('tenant_b') + end + + after(:all) do + Apartment::Tenant.drop('tenant_a') + Apartment::Tenant.drop('tenant_b') + Apartment.clear_config + end + + it 'isolates data between tenants' do + Apartment::Tenant.switch('tenant_a') do + User.create!(name: 'Alice') + end + + Apartment::Tenant.switch('tenant_b') do + expect(User.count).to eq(0) + end + + Apartment::Tenant.switch('tenant_a') do + expect(User.count).to eq(1) + end + end + + it 'restores tenant on exception' do + expect { + Apartment::Tenant.switch('tenant_a') do + raise 'boom' + end + }.to raise_error('boom') + + expect(Apartment::Tenant.current).to eq(default_for_engine) + end + + it 'supports nested switching' do + Apartment::Tenant.switch('tenant_a') do + Apartment::Tenant.switch('tenant_b') do + expect(Apartment::Tenant.current).to eq('tenant_b') + end + expect(Apartment::Tenant.current).to eq('tenant_a') + end + end +end +``` + +### Gemfile updates + +```ruby +group :development, :test do + gem 'pg', '>= 1.5' + gem 'mysql2', '>= 0.5' + gem 'trilogy', '>= 2.7' + gem 'sqlite3', '>= 2.0' +end +``` + +--- + +## Task 12: Delete v3 adapter files and clean up + +**Files:** +- Delete: `lib/apartment/adapters/abstract_jdbc_adapter.rb` +- Delete: `lib/apartment/adapters/jdbc_postgresql_adapter.rb` +- Delete: `lib/apartment/adapters/jdbc_mysql_adapter.rb` +- Delete: `lib/apartment/adapters/postgis_adapter.rb` +- Delete: `lib/apartment/model.rb` +- Delete: `lib/apartment/active_record/` (entire directory) +- Update: `lib/apartment.rb` (clean up Zeitwerk ignores) + +### Verification + +After deletion: +- `bundle exec rspec spec/unit/` passes +- `DB=sqlite3 bundle exec rspec spec/integration/` passes +- `DB=postgresql bundle exec rspec spec/integration/` passes (if PG available) +- Zeitwerk eager load: `bundle exec ruby -e "require 'apartment'; Zeitwerk::Loader.eager_load_all"` + +--- + +## Sub-Phases + +Phase 2 is split into sub-phases that can be executed as separate PRs on the same branch (`man/v4-adapters`). Each sub-phase produces a working, testable increment. + +### Phase 2.1: Core Structure (Tasks 1, 3, 8) + +**Branch:** `man/v4-adapters` (first PR) + +**What:** The skeleton everything plugs into. +- Task 1: `Apartment::Tenant` public API (switch, current, reset, create/drop delegation) +- Task 3: `Apartment::Adapters::AbstractAdapter` (lifecycle, callbacks, resolve_connection_config interface) +- Task 8: Adapter factory in `Apartment.adapter` + Zeitwerk wiring + +**Produces:** Working `Apartment::Tenant.switch("acme") { ... }` with Current, and `Apartment.adapter` resolving the right adapter class. No real database operations yet — adapter subclasses come next. + +**Tests:** All unit tests with mocked adapters. No database required. + +**Estimated scope:** ~6 files to create/modify, ~40 test examples + +### Phase 2.2: Database Adapters (Tasks 4, 5, 6, 7) + +**What:** Concrete adapter implementations. These are independent of each other. +- Task 4: `PostgreSQLSchemaAdapter` (CREATE/DROP SCHEMA, schema_search_path) +- Task 5: `PostgreSQLDatabaseAdapter` (CREATE/DROP DATABASE on PostgreSQL) +- Task 6: `MySQL2Adapter` + `TrilogyAdapter` (CREATE/DROP DATABASE on MySQL) +- Task 7: `SQLite3Adapter` (file-per-tenant) + +**Produces:** All five adapter classes implemented with `resolve_connection_config`, `create_tenant`, `drop_tenant`. + +**Tests:** Unit tests with mocked database connections for config resolution. Database-specific lifecycle tests can use real DB if available, SQLite3 as fallback. + +**Estimated scope:** ~5 files to create, ~30 test examples + +### Phase 2.3: Connection Handling & Pool Wiring (Tasks 2, 9) + +**What:** The architecturally complex piece — ActiveRecord patching. +- Task 2: `Apartment::Patches::ConnectionHandling` module definition +- Task 9: Full implementation using AR's `establish_connection` with shard-based pool keying + +**Produces:** `ActiveRecord::Base.connection_pool` returns tenant-specific pools when `Current.tenant` is set. Pools are lazily created and cached in PoolManager. + +**Tests:** Tests with real SQLite3 database proving pool isolation. This is where the pool-per-tenant architecture is validated. + +**Estimated scope:** ~2 files to create, ~15 test examples. High complexity — most time spent here. + +### Phase 2.4: Excluded Models & Integration (Tasks 10, 11) + +**What:** Cross-cutting validation. +- Task 10: Excluded model processing (establish_connection pinned to default) +- Task 11: End-to-end integration tests with real PostgreSQL, MySQL, SQLite + +**Produces:** Full working system. `Apartment::Tenant.switch("acme") { User.count }` works against real databases. Excluded models bypass tenant switching. + +**Tests:** Integration tests requiring database services. Gemfile updated with `pg`, `mysql2`, `trilogy`, `sqlite3`. + +**Estimated scope:** ~5 files, ~25 test examples + +### Phase 2.5: Cleanup (Task 12) + +**What:** Delete replaced v3 files, clean up Zeitwerk ignores. + +**Produces:** Clean `lib/apartment/adapters/` directory with only v4 files. Zeitwerk loads without warnings. + +**Estimated scope:** File deletions, Zeitwerk cleanup, verification pass + +--- + +## Sub-Phase Dependency Graph + +``` +Phase 2.1: Core Structure (Tasks 1, 3, 8) + | + +---------------------------+ + | | +Phase 2.2: Database Adapters Phase 2.3: Connection Handling +(Tasks 4, 5, 6, 7) (Tasks 2, 9) + | | + +---------------------------+ + | +Phase 2.4: Excluded Models & Integration (Tasks 10, 11) + | +Phase 2.5: Cleanup (Task 12) +``` + +Phase 2.2 and 2.3 are independent and can be done in either order. Phase 2.3 is more complex and architecturally risky — may benefit from being done first to surface issues early. + +--- + +## Completion Checklist + +- [ ] All unit specs pass: `bundle exec rspec spec/unit/` +- [ ] Integration specs pass with SQLite: `DB=sqlite3 bundle exec rspec spec/integration/` +- [ ] Integration specs pass with PostgreSQL: `DB=postgresql bundle exec rspec spec/integration/` +- [ ] Integration specs pass with MySQL: `DB=mysql bundle exec rspec spec/integration/` +- [ ] Zeitwerk eager load clean: `bundle exec ruby -e "require 'apartment'; Zeitwerk::Loader.eager_load_all"` +- [ ] No v3 adapter files remain (except those still needed by later phases) +- [ ] `Apartment::Tenant.switch("acme") { User.count }` works end-to-end +- [ ] All commits on branch, ready for PR + +## Notes from Phase 1 review (deferred to Phase 2) + +These items were flagged during Phase 1 review and should be addressed during this phase: + +- [x] Freeze Config after validate! (now that adapters consume it) — done in Phase 2.1 +- [x] Consider converting PoolReaper from class singleton to instance — done in Phase 2.3 +- [x] Add switch/reset methods to Current — decided against: Tenant.switch/reset use Current attributes directly; Current stays thin (just attributes) +- [x] Resolve any remaining persistent_schemas usage (now only on PostgreSQLConfig) — PostgreSQLSchemaAdapter reads from config.postgres_config with nil guard (Phase 2.2) + +## Notes from Phase 2.1 review (deferred to later sub-phases) + +Flagged during comprehensive PR review of Phase 2.1. Categorized by target sub-phase. + +### Phase 2.2 (Database Adapters) + +- [x] Adapter factory routing tests assert LoadError/NameError for missing v4 files — rewritten to verify concrete adapter instantiation (Phase 2.2) +- [x] `environmentify` does not guard against `Rails` being undefined — added `rails_env` private method with `ConfigurationError` guard (Phase 2.2) + +### Phase 2.3 (Connection Handling & Pool Wiring) + +- [x] PoolReaper evict_idle/evict_lru do not call `disconnect!` on evicted pools — done in Phase 2.3 (deregister_from_ar_handler calls remove_connection_pool which disconnects) +- [x] `configure` teardown sequence not protected — done in Phase 2.3 (teardown_old_state rescues PoolReaper.stop failures) + +### Phase 2.4 (Excluded Models & Integration) + +- [ ] `process_excluded_models` — wrap `constantize` NameError with `ConfigurationError` for clear boot-time error messages +- [ ] `seed` method — raise when configured seed file doesn't exist instead of silent no-op +- [ ] `AbstractAdapter#drop` — rescue around `disconnect!` so pool cleanup failure doesn't mask successful tenant drop + +### General (address when touched) + +- [ ] PoolReaper broad `rescue => e` in reap — consider narrowing to `ApartmentError` + `ActiveRecord::ActiveRecordError` in inner rescue loops +- [ ] `warn` calls in PoolReaper and PoolManager — migrate to `Rails.logger.error` when logging abstraction is built +- [ ] `define_callbacks :switch` is declared but never used — document as reserved or remove +- [ ] `Tenant.current` returns nil when unconfigured — consider raising `ConfigurationError` for fail-fast +- [ ] `Tenant.switch(nil)` silently sets tenant to nil — consider guarding with `ArgumentError` + +### Test gaps (criticality 5-6, pick up opportunistically) + +- [ ] `drop` partial failure when `drop_tenant` raises — document whether pool cleanup occurs +- [ ] LRU eviction default-tenant protection — direct test for LRU path (idle path tested) +- [ ] `Apartment.activate!` — no test exercises the full `require_relative` + `prepend` path (connection_handling_spec prepends directly) +- [ ] `deregister_all_tenant_pools` — no test verifies AR handler entries are actually removed during teardown (hard without real AR) +- [ ] Concurrent `connection_pool` access — no multi-threaded test exercising concurrent tenant pool creation from parallel threads +- [ ] `AbstractAdapter#drop` + AR handler cleanup — no test verifying the shard entry is removed from AR's ConnectionHandler after drop +- [ ] Fiber isolation for `Current` — validate the core v4 design claim with a fiber test +- [ ] `PoolManager#clear` disconnect verification — assert `disconnect!` called, not just count drops +- [ ] Concurrent `remove` + `get` race — document `Concurrent::Map` guarantees with a test diff --git a/docs/plans/apartment-v4/phase-2.3-connection-handling.md b/docs/plans/apartment-v4/phase-2.3-connection-handling.md new file mode 100644 index 00000000..34c56b36 --- /dev/null +++ b/docs/plans/apartment-v4/phase-2.3-connection-handling.md @@ -0,0 +1,1145 @@ +# Phase 2.3: Connection Handling & Pool Wiring — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire `Apartment::Current.tenant` to ActiveRecord's connection pool resolution so that `Tenant.switch("acme") { User.count }` transparently uses a tenant-specific connection pool. + +**Architecture:** A single module prepended on `ActiveRecord::Base` overrides `connection_pool` to return tenant-specific pools via AR's `establish_connection` with shard-based keying. PoolReaper is converted from class singleton to instance and gains AR handler cleanup on eviction. Config gets `shard_key_prefix` for namespacing. + +**Tech Stack:** Ruby 3.3+, ActiveRecord 7.2+, SQLite3 (for unit tests), RSpec, `concurrent-ruby` + +**Spec:** [`docs/designs/phase-2.3-connection-handling.md`](../../designs/phase-2.3-connection-handling.md) + +--- + +## File Map + +### New files + +| File | Responsibility | +|------|---------------| +| `lib/apartment/patches/connection_handling.rb` | `connection_pool` override — the core patch | +| `spec/unit/patches/connection_handling_spec.rb` | Unit tests for pool resolution with real AR + SQLite3 | + +### Modified files + +| File | Change summary | +|------|---------------| +| `lib/apartment/config.rb` | Add `shard_key_prefix` attr, `rails_env_name` method, validation | +| `lib/apartment/pool_reaper.rb` | Convert class singleton → instance, add AR handler cleanup in eviction | +| `lib/apartment.rb` | Add `pool_reaper` accessor, `activate!`, extract `teardown_old_state` with AR handler deregistration, update `configure`/`clear_config` | +| `spec/unit/config_spec.rb` | Tests for `shard_key_prefix` and `rails_env_name` | +| `spec/unit/pool_reaper_spec.rb` | Rewrite for instance API | +| `spec/unit/apartment_spec.rb` | Tests for `activate!`, teardown protection, `pool_reaper` accessor | + +--- + +## Task 1: Config — `shard_key_prefix` and `rails_env_name` + +**Files:** +- Modify: `lib/apartment/config.rb` +- Modify: `spec/unit/config_spec.rb` + +Foundation for all other tasks — the shard key prefix and env name are used by ConnectionHandling and PoolReaper. + +- [ ] **Step 1: Write failing tests for `shard_key_prefix` default and validation** + +Add to `spec/unit/config_spec.rb` in the `defaults` describe block: + +```ruby +it { expect(config.shard_key_prefix).to(eq('apartment')) } +``` + +Add a new describe block after `#environmentify_strategy=`: + +```ruby +describe '#shard_key_prefix validation' do + before do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + it 'passes with default value' do + expect { config.validate! }.not_to(raise_error) + end + + it 'passes with custom valid prefix' do + config.shard_key_prefix = 'myapp_tenant' + expect { config.validate! }.not_to(raise_error) + end + + it 'raises for empty string' do + config.shard_key_prefix = '' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end + + it 'raises for string starting with number' do + config.shard_key_prefix = '1bad' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end + + it 'raises for string with special characters' do + config.shard_key_prefix = 'my-prefix' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end + + it 'raises for non-string' do + config.shard_key_prefix = :symbol + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/config_spec.rb --format documentation` +Expected: Multiple failures (undefined method `shard_key_prefix`) + +- [ ] **Step 3: Implement `shard_key_prefix` on Config** + +In `lib/apartment/config.rb`: + +Add `shard_key_prefix` to `attr_accessor` list (line 18): +```ruby +attr_accessor :tenants_provider, :default_tenant, :excluded_models, + :tenant_pool_size, :pool_idle_timeout, :max_total_connections, + :seed_after_create, :seed_data_file, + :parallel_migration_threads, + :elevator, :elevator_options, + :tenant_not_found_handler, :active_record_log, + :shard_key_prefix +``` + +Add default in `initialize` (after line 41, before `@postgres_config`): +```ruby +@shard_key_prefix = 'apartment' +``` + +Add validation at end of `validate!` method (before the closing `end`): +```ruby +unless @shard_key_prefix.is_a?(String) && @shard_key_prefix.match?(/\A[a-z_][a-z0-9_]*\z/) + raise(ConfigurationError, + "shard_key_prefix must be a lowercase string matching /[a-z_][a-z0-9_]*/, got: #{@shard_key_prefix.inspect}") +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/config_spec.rb --format documentation` +Expected: All PASS + +- [ ] **Step 5: Write failing test for `rails_env_name`** + +Add to `spec/unit/config_spec.rb`: + +```ruby +describe '#rails_env_name' do + it 'returns Rails.env when Rails is defined' do + stub_const('Rails', double(env: 'test')) + expect(config.rails_env_name).to(eq('test')) + end + + it 'falls back to RAILS_ENV env var' do + hide_const('Rails') if defined?(Rails) + allow(ENV).to(receive(:[]).and_call_original) + allow(ENV).to(receive(:[]).with('RAILS_ENV').and_return('staging')) + expect(config.rails_env_name).to(eq('staging')) + end + + it 'falls back to RACK_ENV env var' do + hide_const('Rails') if defined?(Rails) + allow(ENV).to(receive(:[]).and_call_original) + allow(ENV).to(receive(:[]).with('RAILS_ENV').and_return(nil)) + allow(ENV).to(receive(:[]).with('RACK_ENV').and_return('production')) + expect(config.rails_env_name).to(eq('production')) + end + + it 'defaults to "default_env" when nothing is set' do + hide_const('Rails') if defined?(Rails) + allow(ENV).to(receive(:[]).and_call_original) + allow(ENV).to(receive(:[]).with('RAILS_ENV').and_return(nil)) + allow(ENV).to(receive(:[]).with('RACK_ENV').and_return(nil)) + expect(config.rails_env_name).to(eq('default_env')) + end +end +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/config_spec.rb -e 'rails_env_name' --format documentation` +Expected: FAIL (undefined method) + +- [ ] **Step 7: Implement `rails_env_name`** + +Add to `lib/apartment/config.rb` as a public method (after `configure_mysql`, before `freeze!`): + +```ruby +# Environment name for AR's HashConfig. Mirrors ActiveRecord::ConnectionHandling::DEFAULT_ENV. +def rails_env_name + (Rails.env if defined?(Rails.env)) || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'default_env' +end +``` + +- [ ] **Step 8: Run all config tests** + +Run: `bundle exec rspec spec/unit/config_spec.rb --format documentation` +Expected: All PASS + +- [ ] **Step 9: Commit** + +```bash +git add lib/apartment/config.rb spec/unit/config_spec.rb +git commit -m "Config: add shard_key_prefix and rails_env_name for Phase 2.3" +``` + +--- + +## Task 2: PoolReaper — Convert to Instance + +**Files:** +- Modify: `lib/apartment/pool_reaper.rb` +- Modify: `spec/unit/pool_reaper_spec.rb` + +Convert PoolReaper from class singleton to instance. Keep the same behavior, change the API surface. AR handler cleanup comes in Task 4 (after ConnectionHandling exists). + +- [ ] **Step 1: Write failing tests for instance API** + +Replace `spec/unit/pool_reaper_spec.rb` entirely: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Apartment::PoolReaper) do + let(:pool_manager) { Apartment::PoolManager.new } + let(:disconnect_calls) { Concurrent::Array.new } + let(:on_evict) { ->(tenant, _pool) { disconnect_calls << tenant } } + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + on_evict: on_evict + ) + end + + after { reaper.stop if reaper.running? } + + describe '#initialize' do + it 'creates a reaper without starting it' do + expect(reaper).not_to(be_running) + end + + it 'raises ArgumentError for zero interval' do + expect { described_class.new(pool_manager: pool_manager, interval: 0, idle_timeout: 1) } + .to(raise_error(ArgumentError, /interval/)) + end + + it 'raises ArgumentError for negative idle_timeout' do + expect { described_class.new(pool_manager: pool_manager, interval: 1, idle_timeout: -1) } + .to(raise_error(ArgumentError, /idle_timeout/)) + end + + it 'raises ArgumentError for non-positive max_total' do + expect { described_class.new(pool_manager: pool_manager, interval: 1, idle_timeout: 1, max_total: 0) } + .to(raise_error(ArgumentError, /max_total/)) + end + end + + describe '#start / #stop' do + it 'can start and stop without error' do + reaper.start + expect(reaper).to(be_running) + reaper.stop + expect(reaper).not_to(be_running) + end + + it 'is idempotent on stop' do + reaper.start + reaper.stop + expect { reaper.stop }.not_to(raise_error) + end + end + + describe 'idle eviction' do + it 'evicts pools idle beyond timeout' do + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + pool_manager.fetch_or_create('fresh') { 'pool_fresh' } + + reaper.start + sleep 0.2 + + expect(disconnect_calls).to(include('stale')) + expect(pool_manager.tracked?('stale')).to(be(false)) + expect(pool_manager.tracked?('fresh')).to(be(true)) + end + end + + describe 'max_total eviction' do + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 999, + max_total: 2, + on_evict: on_evict + ) + end + + it 'evicts LRU pools when over max' do + 3.times do |i| + pool_manager.fetch_or_create("tenant_#{i}") { "pool_#{i}" } + pool_manager.instance_variable_get(:@timestamps)["tenant_#{i}"] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - (300 - (i * 100)) + end + + reaper.start + sleep 0.2 + + expect(pool_manager.stats[:total_pools]).to(be <= 2) + expect(disconnect_calls).to(include('tenant_0')) + end + end + + describe 'protected tenants' do + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + default_tenant: 'public', + on_evict: on_evict + ) + end + + it 'never evicts the default tenant' do + pool_manager.fetch_or_create('public') { 'pool_default' } + pool_manager.instance_variable_get(:@timestamps)['public'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 9999 + + reaper.start + sleep 0.2 + + expect(pool_manager.tracked?('public')).to(be(true)) + expect(disconnect_calls).not_to(include('public')) + end + end + + describe 'double start' do + it 'stops the previous timer before starting a new one' do + reaper.start + expect(reaper).to(be_running) + reaper.start + expect(reaper).to(be_running) + reaper.stop + expect(reaper).not_to(be_running) + end + end + + describe 'error resilience' do + it 'continues running when on_evict callback raises' do + bad_callback = ->(_tenant, _pool) { raise('callback explosion') } + resilient_reaper = described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + on_evict: bad_callback + ) + + pool_manager.fetch_or_create('tenant_a') { 'pool_a' } + pool_manager.instance_variable_get(:@timestamps)['tenant_a'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + resilient_reaper.start + sleep 0.3 + + expect(resilient_reaper).to(be_running) + expect(pool_manager.tracked?('tenant_a')).to(be(false)) + ensure + resilient_reaper&.stop + end + end + + describe 'instrumentation' do + it 'emits evict.apartment events on eviction' do + events = Concurrent::Array.new + ActiveSupport::Notifications.subscribe('evict.apartment') { |event| events << event } + + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + reaper.start + sleep(0.2) + + expect(events.any? { |e| e.payload[:tenant] == 'stale' }).to(be(true)) + ensure + ActiveSupport::Notifications.unsubscribe('evict.apartment') + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb --format documentation` +Expected: FAIL (class methods vs instance methods) + +- [ ] **Step 3: Rewrite `pool_reaper.rb` as instance-based** + +Replace `lib/apartment/pool_reaper.rb`: + +```ruby +# frozen_string_literal: true + +require 'concurrent' +require_relative 'instrumentation' + +module Apartment + # Evicts idle and excess tenant pools on a background timer. + # Complementary to ActiveRecord's ConnectionPool::Reaper which handles + # intra-pool connection reaping — this handles inter-pool (tenant) eviction. + class PoolReaper + def initialize(pool_manager:, interval:, idle_timeout:, max_total: nil, + default_tenant: nil, shard_key_prefix: nil, on_evict: nil) + raise(ArgumentError, 'interval must be a positive number') unless interval.is_a?(Numeric) && interval.positive? + unless idle_timeout.is_a?(Numeric) && idle_timeout.positive? + raise(ArgumentError, 'idle_timeout must be a positive number') + end + if max_total && (!max_total.is_a?(Integer) || max_total < 1) + raise(ArgumentError, 'max_total must be a positive integer or nil') + end + + @pool_manager = pool_manager + @interval = interval + @idle_timeout = idle_timeout + @max_total = max_total + @default_tenant = default_tenant + @shard_key_prefix = shard_key_prefix + @on_evict = on_evict + @mutex = Mutex.new + @timer = nil + end + + def start + @mutex.synchronize do + stop_internal + @timer = Concurrent::TimerTask.new(execution_interval: @interval) { reap } + @timer.execute + end + end + + def stop + @mutex.synchronize { stop_internal } + end + + def running? + @mutex.synchronize { @timer&.running? || false } + end + + private + + def stop_internal + return unless @timer + + @timer.shutdown + @timer.wait_for_termination(5) + @timer = nil + end + + def reap + evict_idle + evict_lru if @max_total + rescue Apartment::ApartmentError => e + warn "[Apartment::PoolReaper] #{e.class}: #{e.message}" + rescue StandardError => e + warn "[Apartment::PoolReaper] Unexpected error: #{e.class}: #{e.message}" + warn e.backtrace&.first(5)&.join("\n") if e.backtrace + end + + def evict_idle + @pool_manager.idle_tenants(timeout: @idle_timeout).each do |tenant| + next if tenant == @default_tenant + + pool = @pool_manager.remove(tenant) + deregister_from_ar_handler(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :idle) + @on_evict&.call(tenant, pool) + rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}" + end + end + + def evict_lru + excess = @pool_manager.stats[:total_pools] - @max_total + return if excess <= 0 + + candidates = @pool_manager.lru_tenants(count: excess + 1) + evicted = 0 + candidates.each do |tenant| + break if evicted >= excess + next if tenant == @default_tenant + + pool = @pool_manager.remove(tenant) + deregister_from_ar_handler(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :lru) + @on_evict&.call(tenant, pool) + evicted += 1 + rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}" + end + end + + def deregister_from_ar_handler(tenant) + return unless @shard_key_prefix && defined?(ActiveRecord::Base) + + shard_key = :"#{@shard_key_prefix}_#{tenant}" + ActiveRecord::Base.connection_handler.remove_connection_pool( + 'ActiveRecord::Base', + role: ActiveRecord::Base.current_role, + shard: shard_key + ) + rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to deregister AR pool for #{tenant}: #{e.class}: #{e.message}" + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb --format documentation` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/pool_reaper.rb spec/unit/pool_reaper_spec.rb +git commit -m "PoolReaper: convert from class singleton to instance + +Addresses deferred Phase 1 review item. Instance API enables test +isolation and removes global mutable state. AR handler deregistration +added (no-op until shard_key_prefix is configured in Phase 2.3)." +``` + +--- + +## Task 3: `Apartment` module — `teardown_old_state`, `activate!`, updated `configure`/`clear_config` + +**Files:** +- Modify: `lib/apartment.rb` +- Modify: `spec/unit/apartment_spec.rb` +- Modify: `spec/spec_helper.rb` + +Wire the instance-based PoolReaper into the Apartment module. Add `activate!` and protected teardown. + +- [ ] **Step 1: Write failing tests** + +Add to `spec/unit/apartment_spec.rb`: + +```ruby +describe '.pool_reaper' do + it 'is nil before configure' do + expect(described_class.pool_reaper).to(be_nil) + end + + it 'is an instance of PoolReaper after configure' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + expect(described_class.pool_reaper).to(be_a(Apartment::PoolReaper)) + end + + it 'is running after configure' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + expect(described_class.pool_reaper).to(be_running) + end + + it 'is nil after clear_config' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + described_class.clear_config + expect(described_class.pool_reaper).to(be_nil) + end +end + +describe '.configure teardown protection' do + it 'completes reconfigure even if reaper stop raises' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + # Sabotage the reaper's stop method + allow(described_class.pool_reaper).to(receive(:stop).and_raise(RuntimeError, 'timer boom')) + + # Reconfigure should still succeed + expect do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'new_default' + end + end.not_to(raise_error) + + expect(described_class.config.default_tenant).to(eq('new_default')) + end +end + +describe '.activate!' do + it 'prepends ConnectionHandling on ActiveRecord::Base' do + # Load the patch module first + require_relative '../../lib/apartment/patches/connection_handling' + + described_class.activate! + expect(ActiveRecord::Base.singleton_class.ancestors).to( + include(Apartment::Patches::ConnectionHandling) + ) + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/apartment_spec.rb -e 'pool_reaper|teardown|activate' --format documentation` +Expected: FAIL + +- [ ] **Step 3: Update `lib/apartment.rb`** + +Replace `lib/apartment.rb`: + +```ruby +# frozen_string_literal: true + +require 'zeitwerk' +require 'active_support' +require 'active_support/current_attributes' + +# Set up Zeitwerk autoloader for the Apartment namespace. +loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false) +loader.inflector.inflect( + 'mysql_config' => 'MySQLConfig', + 'postgresql_config' => 'PostgreSQLConfig' +) + +# errors.rb defines multiple constants (not a single Errors class). +loader.ignore("#{__dir__}/apartment/errors.rb") + +# Ignore v3 files that haven't been replaced yet. +%w[ + railtie + deprecation + log_subscriber + console + custom_console + migrator + model +].each { |f| loader.ignore("#{__dir__}/apartment/#{f}.rb") } + +loader.ignore("#{__dir__}/apartment/adapters") +loader.ignore("#{__dir__}/apartment/elevators") +loader.ignore("#{__dir__}/apartment/patches") +loader.ignore("#{__dir__}/apartment/tasks") +loader.ignore("#{__dir__}/apartment/active_record") + +loader.setup + +require_relative 'apartment/errors' + +module Apartment + class << self + attr_reader :config, :pool_manager, :pool_reaper + attr_writer :adapter + + # Lazy-loading adapter. Built on first access via build_adapter. + def adapter + @adapter ||= build_adapter + end + + # Configure Apartment v4. Yields a Config instance, validates it, + # and prepares the module for use. + def configure + raise(ConfigurationError, 'Apartment.configure requires a block') unless block_given? + + # Prepare-then-swap: build and validate new config before tearing down + # old state. If the block or validate! raises, the previous working + # configuration is preserved. + new_config = Config.new + yield(new_config) + new_config.validate! + new_config.freeze! + + # Validation passed — tear down old state and swap in new. + teardown_old_state + @config = new_config + @pool_manager = PoolManager.new + @pool_reaper = PoolReaper.new( + pool_manager: @pool_manager, + interval: new_config.pool_idle_timeout, + idle_timeout: new_config.pool_idle_timeout, + max_total: new_config.max_total_connections, + default_tenant: new_config.default_tenant, + shard_key_prefix: new_config.shard_key_prefix + ) + @pool_reaper.start + @config + end + + # Reset all configuration and stop background tasks. + def clear_config + teardown_old_state + @config = nil + @pool_manager = nil + @pool_reaper = nil + end + + # Activate the ConnectionHandling patch on ActiveRecord::Base. + # Idempotent — prepend on an already-prepended module is a no-op. + def activate! + require_relative 'apartment/patches/connection_handling' + ActiveRecord::Base.singleton_class.prepend(Patches::ConnectionHandling) + end + + private + + # Safely tear down old state. Deregisters tenant pools from AR's + # ConnectionHandler before clearing, then stops the reaper. + # Rescues failures so a broken timer doesn't prevent reconfiguration. + def teardown_old_state + begin + @pool_reaper&.stop + rescue StandardError => e + warn "[Apartment] PoolReaper.stop failed during teardown: #{e.class}: #{e.message}" + end + deregister_all_tenant_pools + @pool_manager&.clear + @adapter = nil + end + + # Deregister all tenant pools from AR's ConnectionHandler. + # Called during teardown to prevent stale shard registrations. + def deregister_all_tenant_pools + return unless @pool_manager && @config && defined?(ActiveRecord::Base) + + prefix = @config.shard_key_prefix + @pool_manager.stats[:tenants]&.each do |tenant_key| + shard_key = :"#{prefix}_#{tenant_key}" + ActiveRecord::Base.connection_handler.remove_connection_pool( + 'ActiveRecord::Base', + role: ActiveRecord::Base.current_role, + shard: shard_key + ) + rescue StandardError => e + warn "[Apartment] Failed to deregister pool for #{tenant_key}: #{e.class}: #{e.message}" + end + end + + # Factory: resolve the correct adapter class based on strategy and database adapter. + def build_adapter + raise(ConfigurationError, 'Apartment not configured. Call Apartment.configure first.') unless @config + + strategy = config.tenant_strategy + db_adapter = detect_database_adapter + + klass = case strategy + when :schema + require_relative('apartment/adapters/postgresql_schema_adapter') + Adapters::PostgreSQLSchemaAdapter + when :database_name + case db_adapter + when /postgresql/, /postgis/ + require_relative('apartment/adapters/postgresql_database_adapter') + Adapters::PostgreSQLDatabaseAdapter + when /mysql2/ + require_relative('apartment/adapters/mysql2_adapter') + Adapters::MySQL2Adapter + when /trilogy/ + require_relative('apartment/adapters/trilogy_adapter') + Adapters::TrilogyAdapter + when /sqlite/ + require_relative('apartment/adapters/sqlite3_adapter') + Adapters::SQLite3Adapter + else + raise(AdapterNotFound, "No adapter for database: #{db_adapter}") + end + else + raise(AdapterNotFound, "Strategy #{strategy} not yet implemented") + end + + klass.new(ActiveRecord::Base.connection_db_config.configuration_hash) + end + + def detect_database_adapter + ActiveRecord::Base.connection_db_config.adapter + end + end +end +``` + +- [ ] **Step 4: Update `spec/spec_helper.rb` teardown** + +The `after` block calls `Apartment.clear_config` which now handles instance-based reaper. No changes needed to spec_helper itself — but verify the existing tests still pass. + +- [ ] **Step 5: Run all unit tests** + +Run: `bundle exec rspec spec/unit/ --format documentation` +Expected: All PASS. The pool_reaper_spec and apartment_spec tests should pass with the new instance API. + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment.rb spec/unit/apartment_spec.rb +git commit -m "Apartment module: instance PoolReaper, activate!, teardown protection + +- configure creates PoolReaper instance (not class singleton) +- clear_config tears down reaper instance +- teardown_old_state rescues reaper.stop failures +- activate! prepends ConnectionHandling patch on AR::Base" +``` + +--- + +## Task 4: `Patches::ConnectionHandling` — the core patch + +**Files:** +- Create: `lib/apartment/patches/connection_handling.rb` +- Create: `spec/unit/patches/connection_handling_spec.rb` + +This is the architecturally sensitive piece. Tests use real AR + SQLite3. + +- [ ] **Step 1: Write failing tests** + +Create `spec/unit/patches/connection_handling_spec.rb`. + +**Note on global state**: The `prepend` on `ActiveRecord::Base` is permanent for the process. This spec loads real AR (not the stub from `apartment_spec.rb`). Since `apartment_spec.rb` conditionally defines the AR stub (`unless defined?(ActiveRecord::Base)`), loading order matters. The `connection_handling_spec.rb` should be run as part of the full suite (which loads AR first via this file), and the `apartment_spec.rb` stub will be skipped when real AR is present. This is fine — the `apartment_spec.rb` tests don't depend on the stub's implementation. + +**Note on `establish_connection` return type**: Verified across Rails 7.2/8.0/8.1 source — `ConnectionHandler#establish_connection` always returns `pool_config.pool` (a `ConnectionPool` instance), never a `PoolConfig`. This matches what `connection_pool` is expected to return. + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'active_record' + +# Set up a real SQLite3 in-memory database for testing pool resolution. +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + +# Ensure the patch is loaded and activated. +require_relative '../../../lib/apartment/patches/connection_handling' +ActiveRecord::Base.singleton_class.prepend(Apartment::Patches::ConnectionHandling) + +RSpec.describe(Apartment::Patches::ConnectionHandling) do + let(:mock_adapter) do + instance_double( + Apartment::Adapters::AbstractAdapter, + resolve_connection_config: { 'adapter' => 'sqlite3', 'database' => ':memory:' } + ) + end + + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { %w[acme widgets] } + config.default_tenant = 'public' + end + Apartment.adapter = mock_adapter + end + + describe '#connection_pool' do + context 'when tenant is nil' do + it 'returns the default pool via super' do + Apartment::Current.reset + pool = ActiveRecord::Base.connection_pool + expect(pool).to(be_a(ActiveRecord::ConnectionAdapters::ConnectionPool)) + end + end + + context 'when tenant is the default tenant' do + it 'returns the default pool via super' do + Apartment::Current.tenant = 'public' + pool = ActiveRecord::Base.connection_pool + default_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: ActiveRecord::Base.current_role, + shard: ActiveRecord::Base.default_shard + ) + expect(pool).to(eq(default_pool)) + end + end + + context 'when tenant is set' do + before { Apartment::Current.tenant = 'acme' } + + it 'returns a tenant-specific pool' do + default_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: ActiveRecord::Base.current_role, + shard: ActiveRecord::Base.default_shard + ) + tenant_pool = ActiveRecord::Base.connection_pool + expect(tenant_pool).not_to(eq(default_pool)) + end + + it 'returns the same pool on subsequent calls (cached)' do + pool1 = ActiveRecord::Base.connection_pool + pool2 = ActiveRecord::Base.connection_pool + expect(pool1).to(equal(pool2)) + end + + it 'returns different pools for different tenants' do + acme_pool = ActiveRecord::Base.connection_pool + + Apartment::Current.tenant = 'widgets' + widgets_pool = ActiveRecord::Base.connection_pool + + expect(acme_pool).not_to(equal(widgets_pool)) + end + + it 'registers the pool with AR ConnectionHandler' do + ActiveRecord::Base.connection_pool # trigger creation + shard_key = :"#{Apartment.config.shard_key_prefix}_acme" + + registered_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: ActiveRecord::Base.current_role, + shard: shard_key + ) + expect(registered_pool).not_to(be_nil) + end + + it 'pool has correct db_config' do + pool = ActiveRecord::Base.connection_pool + expect(pool.db_config.configuration_hash).to(include(adapter: 'sqlite3')) + end + + it 'pool is usable for queries' do + pool = ActiveRecord::Base.connection_pool + result = pool.with_connection { |conn| conn.execute('SELECT 1') } + expect(result).not_to(be_nil) + end + + it 'tracks the pool in PoolManager' do + ActiveRecord::Base.connection_pool + expect(Apartment.pool_manager.tracked?('acme')).to(be(true)) + end + end + + context 'when pool_manager is nil (unconfigured)' do + it 'returns the default pool' do + Apartment.clear_config + Apartment::Current.tenant = 'acme' + + # Should not raise — falls through to super + pool = ActiveRecord::Base.connection_pool + expect(pool).to(be_a(ActiveRecord::ConnectionAdapters::ConnectionPool)) + end + end + + context 'with hyphenated tenant name' do + before { Apartment::Current.tenant = 'my-tenant' } + + it 'works correctly as shard key' do + pool = ActiveRecord::Base.connection_pool + shard_key = :"#{Apartment.config.shard_key_prefix}_my-tenant" + + registered = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: ActiveRecord::Base.current_role, + shard: shard_key + ) + expect(registered).to(eq(pool)) + end + end + + context 'role interaction' do + it 'creates separate pools per (tenant, role) pair' do + Apartment::Current.tenant = 'acme' + writing_pool = ActiveRecord::Base.connection_pool + + # Simulate reading role — push to connected_to_stack + ActiveRecord::Base.connected_to(role: :reading) do + # Our override uses current_role, which is now :reading. + # establish_connection will create a separate pool for (acme, reading). + # However, this will raise ConnectionNotEstablished if no reading + # pool was previously registered. This test verifies the role + # parameter is passed through correctly by checking the shard key + # registration rather than the full connected_to flow. + end + + # Verify the writing pool was registered with the writing role + shard_key = :"#{Apartment.config.shard_key_prefix}_acme" + registered = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: ActiveRecord::Base.default_role, + shard: shard_key + ) + expect(registered).to(eq(writing_pool)) + end + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/patches/connection_handling_spec.rb --format documentation` +Expected: FAIL (file not found or module not defined) + +- [ ] **Step 3: Implement `connection_handling.rb`** + +Create `lib/apartment/patches/connection_handling.rb`: + +```ruby +# frozen_string_literal: true + +require 'active_record' + +module Apartment + module Patches + # Prepended on ActiveRecord::Base (singleton class) to intercept + # connection_pool lookups. When Apartment::Current.tenant is set, + # returns a tenant-specific pool with immutable, tenant-scoped config. + # + # Uses AR's establish_connection with shard-based keying — each tenant + # is a shard within AR's ConnectionHandler. Pools are lazily created + # on first access and cached in Apartment::PoolManager for eviction + # tracking. + module ConnectionHandling + def connection_pool + tenant = Apartment::Current.tenant + default = Apartment.config&.default_tenant + + return super if tenant.nil? || tenant == default + return super unless Apartment.pool_manager + + pool_key = tenant.to_s + + Apartment.pool_manager.fetch_or_create(pool_key) do + config = Apartment.adapter.resolve_connection_config(tenant) + shard_key = :"#{Apartment.config.shard_key_prefix}_#{tenant}" + + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + Apartment.config.rails_env_name, + "apartment_#{tenant}", + config + ) + + ActiveRecord::Base.connection_handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: ActiveRecord::Base.current_role, + shard: shard_key + ) + end + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/patches/connection_handling_spec.rb --format documentation` +Expected: All PASS + +- [ ] **Step 5: Run full unit test suite** + +Run: `bundle exec rspec spec/unit/ --format documentation` +Expected: All PASS + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/patches/connection_handling.rb spec/unit/patches/connection_handling_spec.rb +git commit -m "ConnectionHandling: tenant-aware pool resolution via AR shard keying + +Core Phase 2.3 deliverable. Prepends on AR::Base to override +connection_pool. Reads Current.tenant, lazily creates pools via +establish_connection with namespaced shard keys. Pools are immutable +and tenant-scoped — no SET search_path at switch time." +``` + +--- + +## Task 5: Cross-cutting cleanup and Appraisal verification + +**Files:** +- Modify: `lib/apartment/CLAUDE.md` (update Phase 2.3 status) +- No code changes — verification only + +- [ ] **Step 1: Run unit tests across all Rails versions** + +```bash +bundle exec appraisal rails-7.2-sqlite3 rspec spec/unit/ --format documentation +bundle exec appraisal rails-8.0-sqlite3 rspec spec/unit/ --format documentation +bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/ --format documentation +``` + +Expected: All PASS across all three versions. + +- [ ] **Step 2: Run rubocop** + +```bash +bundle exec rubocop lib/apartment/patches/connection_handling.rb lib/apartment/pool_reaper.rb lib/apartment/config.rb lib/apartment.rb +``` + +Expected: No offenses. Fix any that arise. + +- [ ] **Step 3: Update `lib/apartment/CLAUDE.md`** + +Update the directory structure to include the new `patches/` directory and `connection_handling.rb`. Mark Phase 2.3 as the current phase. + +- [ ] **Step 4: Update deferred items in phase plan** + +In `docs/plans/apartment-v4/phase-2-adapters.md`, check off the Phase 2.3 deferred items: +- `[x] PoolReaper evict_idle/evict_lru do not call disconnect! on evicted pools` +- `[x] configure teardown sequence not protected` +- `[x] Consider converting PoolReaper from class singleton to instance` + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/CLAUDE.md docs/plans/apartment-v4/phase-2-adapters.md +git commit -m "Update docs: Phase 2.3 complete, deferred items resolved" +``` + +--- + +## Task 6: Commit design and research docs + +**Files:** +- `docs/designs/phase-2.3-connection-handling.md` (already written) +- `docs/research/connection-handling-internals.md` (already written) +- `docs/plans/apartment-v4/phase-2.3-connection-handling.md` (this plan) + +- [ ] **Step 1: Commit all planning docs** + +```bash +git add docs/designs/phase-2.3-connection-handling.md docs/research/connection-handling-internals.md docs/plans/apartment-v4/phase-2.3-connection-handling.md +git commit -m "Phase 2.3 planning: design spec, research doc, implementation plan" +``` + +This should be the **first commit** on the branch (reorder if implementing via subagent-driven-development). + +--- + +## Dependency Graph + +``` +Task 6 (docs) — no deps, commit first +Task 1 (Config) — no deps +Task 2 (PoolReaper)— no deps +Task 3 (Apartment) — depends on Task 1, Task 2 +Task 4 (Patch) — depends on Task 1, Task 3 +Task 5 (Verify) — depends on all above +``` + +Tasks 1 and 2 are independent and can run in parallel. diff --git a/docs/plans/apartment-v4/phase-5-rbac-roles-schema-cache.md b/docs/plans/apartment-v4/phase-5-rbac-roles-schema-cache.md new file mode 100644 index 00000000..911eeff5 --- /dev/null +++ b/docs/plans/apartment-v4/phase-5-rbac-roles-schema-cache.md @@ -0,0 +1,1522 @@ +# Phase 5: Role-Aware Connections, RBAC, Schema Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Apartment v4's `ConnectionHandling` role-aware so `connected_to(role:)` composes correctly with `Tenant.switch`, enabling RBAC credential separation, replica routing, RBAC privilege grants, per-tenant schema cache, and pending migration checks. + +**Architecture:** `ConnectionHandling#connection_pool` resolves the base config from the current role's default pool via `super`, then lets the adapter apply tenant-specific modifications. Pool keys become `"tenant:role"`. The Migrator wraps work in `connected_to(role: migration_role)`. RBAC grants execute in adapter subclasses after tenant creation. + +**Tech Stack:** Ruby 3.3+, Rails 7.2/8.0/8.1, ActiveRecord, ActiveSupport::CurrentAttributes, Concurrent::Map, RSpec + +**Spec:** `docs/designs/v4-phase5-rbac-roles-schema-cache.md` + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `lib/apartment/current.rb` | Modify | Add `:migrating` attribute | +| `lib/apartment/errors.rb` | Modify | Add `PendingMigrationError` | +| `lib/apartment/config.rb` | Modify | Add 4 new config keys + validation | +| `lib/apartment/tenant_name_validator.rb` | Modify | Add colon to common blacklist | +| `lib/apartment/pool_manager.rb` | Modify | Add `remove_tenant`, `evict_by_role` | +| `lib/apartment.rb` | Modify | Update `deregister_shard` for composite keys | +| `lib/apartment/pool_reaper.rb` | Modify | Prefix-based default tenant guard | +| `lib/apartment/adapters/abstract_adapter.rb` | Modify | `base_config_override:`, grants dispatch, `drop` update | +| `lib/apartment/adapters/postgresql_schema_adapter.rb` | Modify | `base_config:` keyword, `grant_privileges` | +| `lib/apartment/adapters/postgresql_database_adapter.rb` | Modify | `base_config:` keyword | +| `lib/apartment/adapters/mysql2_adapter.rb` | Modify | `base_config:` keyword, `grant_privileges` | +| `lib/apartment/adapters/sqlite3_adapter.rb` | Modify | `base_config:` keyword | +| `lib/apartment/patches/connection_handling.rb` | Modify | Role-aware resolution, pending check, schema cache | +| `lib/apartment/migrator.rb` | Modify | `with_migration_role`, `Current.migrating`, eviction | +| `lib/apartment/schema_cache.rb` | Create | `dump`, `dump_all`, `cache_path_for` | +| `lib/apartment/tasks/v4.rake` | Modify | Add `apartment:schema:cache:dump` | + +--- + +## Task Ordering & Dependencies + +Tasks 1-4 are foundation (no inter-dependencies, can be parallelized). Tasks 5-7 depend on 1-4. Task 8 depends on 5. Tasks 9-10 are independent leaf nodes. + +``` +Task 1 (Current + Errors) ─┐ +Task 2 (Config) ─┼─► Task 5 (Adapter interface) ─► Task 7 (ConnectionHandling) ─► Task 8 (Migrator) +Task 3 (PoolManager) ─┤ │ +Task 4 (TenantNameValidator)┘ Task 6 (RBAC grants) ────────────────────────────────────────────► │ + Task 9 (SchemaCache) ─────────────────────────────────────────────►│ + Task 10 (Rake tasks) ──────────────────────────────────────────────►│ +``` + +--- + +### Task 1: Current.migrating + PendingMigrationError + +**Files:** +- Modify: `lib/apartment/current.rb:9` +- Modify: `lib/apartment/errors.rb:37` +- Test: `spec/unit/current_spec.rb` (existing, add cases) +- Test: `spec/unit/errors_spec.rb` (existing, add cases) + +- [ ] **Step 1: Write failing test for Current.migrating** + +In `spec/unit/current_spec.rb`, add: + +```ruby +describe '.migrating' do + it 'defaults to nil' do + expect(Apartment::Current.migrating).to(be_nil) + end + + it 'can be set and read' do + Apartment::Current.migrating = true + expect(Apartment::Current.migrating).to(be(true)) + ensure + Apartment::Current.migrating = nil + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/current_spec.rb -e 'migrating' -f doc` +Expected: FAIL — `migrating` attribute doesn't exist + +- [ ] **Step 3: Add migrating attribute to Current** + +In `lib/apartment/current.rb:9`, change: +```ruby +attribute :tenant, :previous_tenant +``` +to: +```ruby +attribute :tenant, :previous_tenant, :migrating +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/current_spec.rb -e 'migrating' -f doc` +Expected: PASS + +- [ ] **Step 5: Write failing test for PendingMigrationError** + +In `spec/unit/errors_spec.rb`, add: + +```ruby +describe Apartment::PendingMigrationError do + it 'includes tenant in message' do + error = described_class.new('acme') + expect(error.message).to(include('acme')) + expect(error.message).to(include('apartment:migrate')) + expect(error.tenant).to(eq('acme')) + end + + it 'has a default message without tenant' do + error = described_class.new + expect(error.message).to(include('apartment:migrate')) + end + + it 'inherits from ApartmentError' do + expect(described_class.new).to(be_a(Apartment::ApartmentError)) + end +end +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/errors_spec.rb -e 'PendingMigrationError' -f doc` +Expected: FAIL — `PendingMigrationError` not defined + +- [ ] **Step 7: Implement PendingMigrationError** + +In `lib/apartment/errors.rb`, after the `SchemaLoadError` class (line 37), add: + +```ruby +# Raised in development when a tenant has pending migrations. +class PendingMigrationError < ApartmentError + attr_reader :tenant + + def initialize(tenant = nil) + @tenant = tenant + super( + tenant ? "Tenant '#{tenant}' has pending migrations. Run apartment:migrate to update." + : 'Tenant has pending migrations. Run apartment:migrate to update.' + ) + end +end +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/errors_spec.rb -e 'PendingMigrationError' -f doc` +Expected: PASS + +- [ ] **Step 9: Run full unit suite to check for regressions** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing + +- [ ] **Step 10: Commit** + +```bash +git add lib/apartment/current.rb lib/apartment/errors.rb spec/unit/current_spec.rb spec/unit/errors_spec.rb +git commit -m "Add Current.migrating attribute and PendingMigrationError" +``` + +--- + +### Task 2: Config — New Keys and Validation + +**Files:** +- Modify: `lib/apartment/config.rb:14-47` (attr_accessor, initialize, validate!, freeze!) +- Test: `spec/unit/config_spec.rb` (existing, add cases) + +- [ ] **Step 1: Write failing tests for new config keys** + +In `spec/unit/config_spec.rb`, add a new `describe` block: + +```ruby +describe 'Phase 5 config keys' do + it 'defaults migration_role to nil' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + end + expect(Apartment.config.migration_role).to(be_nil) + end + + it 'defaults app_role to nil' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + end + expect(Apartment.config.app_role).to(be_nil) + end + + it 'defaults schema_cache_per_tenant to false' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + end + expect(Apartment.config.schema_cache_per_tenant).to(be(false)) + end + + it 'defaults check_pending_migrations to true' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + end + expect(Apartment.config.check_pending_migrations).to(be(true)) + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/config_spec.rb -e 'Phase 5' -f doc` +Expected: FAIL — methods not defined + +- [ ] **Step 3: Add attr_accessors and defaults to Config** + +In `lib/apartment/config.rb:15`, add to `attr_accessor` line: +```ruby +:migration_role, :app_role, +:schema_cache_per_tenant, :check_pending_migrations, +``` + +In `lib/apartment/config.rb` `initialize` method, add: +```ruby +@migration_role = nil +@app_role = nil +@schema_cache_per_tenant = false +@check_pending_migrations = true +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/config_spec.rb -e 'Phase 5' -f doc` +Expected: PASS + +- [ ] **Step 5: Write failing validation tests** + +```ruby +describe 'Phase 5 validation' do + it 'rejects non-symbol migration_role' do + expect { + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.migration_role = 'db_manager' + end + }.to(raise_error(Apartment::ConfigurationError, /migration_role/)) + end + + it 'accepts symbol migration_role' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.migration_role = :db_manager + end + expect(Apartment.config.migration_role).to(eq(:db_manager)) + end + + it 'rejects non-string non-callable app_role' do + expect { + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.app_role = 123 + end + }.to(raise_error(Apartment::ConfigurationError, /app_role/)) + end + + it 'accepts string app_role' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.app_role = 'app_user' + end + expect(Apartment.config.app_role).to(eq('app_user')) + end + + it 'accepts callable app_role' do + custom_grants = ->(tenant, conn) {} + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.app_role = custom_grants + end + expect(Apartment.config.app_role).to(eq(custom_grants)) + end +end +``` + +- [ ] **Step 6: Run validation tests to verify they fail** + +Run: `bundle exec rspec spec/unit/config_spec.rb -e 'Phase 5 validation' -f doc` +Expected: FAIL — no validation yet + +- [ ] **Step 7: Add validation logic to Config#validate!** + +In `lib/apartment/config.rb`, in `validate!`, add **before** the `shard_key_prefix` guard (before line 123's `return if @shard_key_prefix...`). The `shard_key_prefix` validation uses an early `return` that would skip any code after it: + +```ruby +if @migration_role && !@migration_role.is_a?(Symbol) + raise(ConfigurationError, "migration_role must be nil or a Symbol, got: #{@migration_role.inspect}") +end + +if @app_role && !@app_role.is_a?(String) && !@app_role.respond_to?(:call) + raise(ConfigurationError, "app_role must be nil, a String, or a callable, got: #{@app_role.inspect}") +end + +unless [true, false].include?(@schema_cache_per_tenant) + raise(ConfigurationError, + "schema_cache_per_tenant must be true or false, got: #{@schema_cache_per_tenant.inspect}") +end + +unless [true, false].include?(@check_pending_migrations) + raise(ConfigurationError, + "check_pending_migrations must be true or false, got: #{@check_pending_migrations.inspect}") +end +``` + +- [ ] **Step 8: Add freeze for string app_role** + +In `Config#freeze!`, before `freeze`, add: +```ruby +@app_role.freeze if @app_role.is_a?(String) +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/config_spec.rb -e 'Phase 5' -f doc` +Expected: PASS + +- [ ] **Step 10: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing + +- [ ] **Step 11: Commit** + +```bash +git add lib/apartment/config.rb spec/unit/config_spec.rb +git commit -m "Add Phase 5 config keys: migration_role, app_role, schema_cache_per_tenant, check_pending_migrations" +``` + +--- + +### Task 3: PoolManager — remove_tenant, evict_by_role + PoolReaper + deregister_shard + +**Files:** +- Modify: `lib/apartment/pool_manager.rb:27-32` (after `remove` method) +- Modify: `lib/apartment/pool_reaper.rb:69-72,86-90` (default tenant guard) +- Modify: `lib/apartment.rb:86-97` (deregister_shard) +- Test: `spec/unit/pool_manager_spec.rb` (existing, add cases) +- Test: `spec/unit/pool_reaper_spec.rb` (existing, add cases) +- Test: `spec/unit/apartment_spec.rb` (existing, add cases) + +- [ ] **Step 1: Write failing tests for PoolManager#remove_tenant** + +In `spec/unit/pool_manager_spec.rb`, add: + +```ruby +describe '#remove_tenant' do + it 'removes all pools for a tenant across roles' do + manager.fetch_or_create('acme:writing') { 'pool_w' } + manager.fetch_or_create('acme:reading') { 'pool_r' } + manager.fetch_or_create('other:writing') { 'pool_o' } + + removed = manager.remove_tenant('acme') + + expect(removed.map(&:first)).to(contain_exactly('acme:writing', 'acme:reading')) + expect(manager.tracked?('acme:writing')).to(be(false)) + expect(manager.tracked?('acme:reading')).to(be(false)) + expect(manager.tracked?('other:writing')).to(be(true)) + end + + it 'returns empty array when no pools match' do + expect(manager.remove_tenant('nonexistent')).to(eq([])) + end +end + +describe '#evict_by_role' do + it 'removes all pools for a given role' do + manager.fetch_or_create('acme:writing') { 'pool_aw' } + manager.fetch_or_create('acme:db_manager') { 'pool_am' } + manager.fetch_or_create('other:db_manager') { 'pool_om' } + manager.fetch_or_create('other:writing') { 'pool_ow' } + + removed = manager.evict_by_role(:db_manager) + + expect(removed.map(&:first)).to(contain_exactly('acme:db_manager', 'other:db_manager')) + expect(manager.tracked?('acme:writing')).to(be(true)) + expect(manager.tracked?('other:writing')).to(be(true)) + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/pool_manager_spec.rb -e 'remove_tenant\|evict_by_role' -f doc` +Expected: FAIL — methods not defined + +- [ ] **Step 3: Implement remove_tenant and evict_by_role** + +In `lib/apartment/pool_manager.rb`, after the `remove` method (after line 32), add: + +```ruby +def remove_tenant(tenant) + prefix = "#{tenant}:" + removed = [] + @pools.each_key do |key| + next unless key.start_with?(prefix) + + pool = remove(key) + removed << [key, pool] if pool + end + removed +end + +def evict_by_role(role) + suffix = ":#{role}" + removed = [] + @pools.each_key do |key| + next unless key.end_with?(suffix) + + pool = remove(key) + removed << [key, pool] if pool + end + removed +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/pool_manager_spec.rb -e 'remove_tenant\|evict_by_role' -f doc` +Expected: PASS + +- [ ] **Step 5: Write failing test for PoolReaper default tenant guard** + +In `spec/unit/pool_reaper_spec.rb`, find the existing idle eviction tests and add: + +```ruby +it 'does not evict pools for the default tenant regardless of role suffix' do + pool_manager.fetch_or_create('public:writing') { mock_pool } + pool_manager.fetch_or_create('public:reading') { mock_pool } + sleep(0.05) # ensure they're idle + + reaper = described_class.new( + pool_manager: pool_manager, interval: 100, idle_timeout: 0.01, + default_tenant: 'public' + ) + reaper.send(:reap) + + expect(pool_manager.tracked?('public:writing')).to(be(true)) + expect(pool_manager.tracked?('public:reading')).to(be(true)) +end +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb -e 'default tenant regardless' -f doc` +Expected: FAIL — guard uses `==` instead of prefix match + +- [ ] **Step 7: Update PoolReaper default tenant guard** + +In `lib/apartment/pool_reaper.rb`, add a private method after `evict_lru` (line 99): + +```ruby +def default_tenant_pool?(pool_key) + return false unless @default_tenant + + pool_key == @default_tenant || pool_key.start_with?("#{@default_tenant}:") +end +``` + +Then change line 71 `next if tenant == @default_tenant` to: +```ruby +next if default_tenant_pool?(tenant) +``` + +And line 90 `next if tenant == @default_tenant` to: +```ruby +next if default_tenant_pool?(tenant) +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb -e 'default tenant' -f doc` +Expected: PASS + +- [ ] **Step 9: Write failing test for deregister_shard composite key** + +In `spec/unit/apartment_spec.rb`, add: + +```ruby +describe '.deregister_shard with composite key' do + it 'extracts role from tenant:role format' do + allow(ActiveRecord::Base.connection_handler).to(receive(:remove_connection_pool)) + + Apartment.deregister_shard('acme:db_manager') + + expect(ActiveRecord::Base.connection_handler).to(have_received(:remove_connection_pool).with( + 'ActiveRecord::Base', + role: :db_manager, + shard: :"#{Apartment.config.shard_key_prefix}_acme:db_manager" + )) + end +end +``` + +- [ ] **Step 10: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/apartment_spec.rb -e 'composite key' -f doc` +Expected: FAIL — uses `current_role` instead of extracting from key + +- [ ] **Step 11: Update deregister_shard** + +In `lib/apartment.rb`, replace the `deregister_shard` method (lines 86-97) with: + +```ruby +def deregister_shard(pool_key) + return unless @config && defined?(ActiveRecord::Base) + + _tenant, _, role_str = pool_key.to_s.rpartition(':') + role = role_str.empty? ? ActiveRecord.writing_role : role_str.to_sym + + shard_key = :"#{@config.shard_key_prefix}_#{pool_key}" + ActiveRecord::Base.connection_handler.remove_connection_pool( + 'ActiveRecord::Base', + role: role, + shard: shard_key + ) +rescue StandardError => e + warn "[Apartment] Failed to deregister AR pool for #{pool_key}: #{e.class}: #{e.message}" +end +``` + +- [ ] **Step 12: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/apartment_spec.rb -e 'composite key' -f doc` +Expected: PASS + +- [ ] **Step 13: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing + +- [ ] **Step 14: Commit** + +```bash +git add lib/apartment/pool_manager.rb lib/apartment/pool_reaper.rb lib/apartment.rb spec/unit/pool_manager_spec.rb spec/unit/pool_reaper_spec.rb spec/unit/apartment_spec.rb +git commit -m "Pool lifecycle: remove_tenant, evict_by_role, composite key deregistration, prefix guard" +``` + +--- + +### Task 4: TenantNameValidator — Colon Restriction + +**Files:** +- Modify: `lib/apartment/tenant_name_validator.rb:26` +- Test: `spec/unit/tenant_name_validator_spec.rb` (existing, add case) + +- [ ] **Step 1: Write failing test** + +In `spec/unit/tenant_name_validator_spec.rb`, add to the `validate_common!` describe block: + +```ruby +it 'rejects names containing colons' do + expect { + described_class.validate!('tenant:name', strategy: :schema) + }.to(raise_error(Apartment::ConfigurationError, /colon/)) +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/tenant_name_validator_spec.rb -e 'colon' -f doc` +Expected: FAIL + +- [ ] **Step 3: Add colon check to validate_common!** + +In `lib/apartment/tenant_name_validator.rb:26`, after the whitespace check, add: + +```ruby +raise(ConfigurationError, "Tenant name contains colon: #{name.inspect}") if name.include?(':') +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/tenant_name_validator_spec.rb -e 'colon' -f doc` +Expected: PASS + +- [ ] **Step 5: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/tenant_name_validator.rb spec/unit/tenant_name_validator_spec.rb +git commit -m "Reject colons in tenant names (composite pool key delimiter)" +``` + +--- + +### Task 5: Adapter Interface — base_config_override + drop Update + +**Files:** +- Modify: `lib/apartment/adapters/abstract_adapter.rb:24-36,53-65` +- Modify: `lib/apartment/adapters/postgresql_schema_adapter.rb:15-19` +- Modify: `lib/apartment/adapters/postgresql_database_adapter.rb` (resolve_connection_config) +- Modify: `lib/apartment/adapters/mysql2_adapter.rb` (resolve_connection_config) +- Modify: `lib/apartment/adapters/sqlite3_adapter.rb:15-17` +- Test: `spec/unit/adapters/postgresql_schema_adapter_spec.rb` (add cases) +- Test: `spec/unit/adapters/abstract_adapter_spec.rb` (add cases) +- Test: `spec/unit/adapters/sqlite3_adapter_spec.rb` (add cases) + +- [ ] **Step 1: Write failing test for base_config_override on PG schema adapter** + +In `spec/unit/adapters/postgresql_schema_adapter_spec.rb`, add: + +```ruby +describe '#validated_connection_config with base_config_override' do + it 'uses the provided base config instead of adapter base_config' do + override = { 'adapter' => 'postgresql', 'host' => 'replica.example.com', 'username' => 'reader' } + config = adapter.validated_connection_config('acme', base_config_override: override) + + expect(config['host']).to(eq('replica.example.com')) + expect(config['username']).to(eq('reader')) + expect(config['schema_search_path']).to(eq('acme')) + end + + it 'falls back to base_config when override is nil' do + config = adapter.validated_connection_config('acme') + expect(config['host']).to(eq('localhost')) + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/adapters/postgresql_schema_adapter_spec.rb -e 'base_config_override' -f doc` +Expected: FAIL — unknown keyword: `base_config_override` + +- [ ] **Step 3: Update AbstractAdapter** + +In `lib/apartment/adapters/abstract_adapter.rb`, replace `validated_connection_config` (lines 24-31): + +```ruby +def validated_connection_config(tenant, base_config_override: nil) + effective_base = base_config_override || base_config + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: effective_base['adapter'] + ) + resolve_connection_config(tenant, base_config: effective_base) +end +``` + +Replace `resolve_connection_config` (lines 35-37): + +```ruby +def resolve_connection_config(tenant, base_config: nil) + raise(NotImplementedError) +end +``` + +- [ ] **Step 4: Update PostgresqlSchemaAdapter** + +In `lib/apartment/adapters/postgresql_schema_adapter.rb`, replace `resolve_connection_config` (lines 15-19): + +```ruby +def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + persistent = Apartment.config.postgres_config&.persistent_schemas || [] + search_path = [tenant, *persistent].join(',') + config.merge('schema_search_path' => search_path) +end +``` + +- [ ] **Step 5: Update remaining adapters** + +PostgresqlDatabaseAdapter — update `resolve_connection_config`: +```ruby +def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + config.merge('database' => environmentify(tenant)) +end +``` + +Mysql2Adapter — same pattern: +```ruby +def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + config.merge('database' => environmentify(tenant)) +end +``` + +Sqlite3Adapter — update `resolve_connection_config`: +```ruby +def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + db_dir = config['database'] ? File.dirname(config['database']) : 'db' + config.merge('database' => File.join(db_dir, "#{environmentify(tenant)}.sqlite3")) +end +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/adapters/postgresql_schema_adapter_spec.rb -e 'base_config_override' -f doc` +Expected: PASS + +- [ ] **Step 7: Write failing test for updated AbstractAdapter#drop** + +In `spec/unit/adapters/abstract_adapter_spec.rb`, add: + +```ruby +describe '#drop with composite pool keys' do + it 'removes all role variants from pool_manager' do + pool_manager = instance_double(Apartment::PoolManager) + allow(Apartment).to(receive(:pool_manager).and_return(pool_manager)) + allow(Apartment).to(receive(:deregister_shard)) + allow(pool_manager).to(receive(:remove_tenant).with('acme').and_return([])) + + adapter.drop('acme') + + expect(pool_manager).to(have_received(:remove_tenant).with('acme')) + end +end +``` + +- [ ] **Step 8: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb -e 'composite pool keys' -f doc` +Expected: FAIL — `remove_tenant` not called (still calls `remove`) + +- [ ] **Step 9: Update AbstractAdapter#drop** + +In `lib/apartment/adapters/abstract_adapter.rb`, replace the `drop` method (lines 53-65): + +```ruby +def drop(tenant) + drop_tenant(tenant) + removed_pools = Apartment.pool_manager&.remove_tenant(tenant) || [] + removed_pools.each do |pool_key, pool| + begin + pool&.disconnect! if pool.respond_to?(:disconnect!) + rescue StandardError => e + warn "[Apartment] Pool disconnect failed for '#{pool_key}': #{e.class}: #{e.message}" + end + deregister_shard_from_ar_handler(pool_key) + end + Instrumentation.instrument(:drop, tenant: tenant) +end +``` + +Update `deregister_shard_from_ar_handler` (line 162-163) to pass through the pool_key: + +```ruby +def deregister_shard_from_ar_handler(pool_key) + Apartment.deregister_shard(pool_key) +end +``` + +- [ ] **Step 10: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb -e 'composite pool keys' -f doc` +Expected: PASS + +- [ ] **Step 11: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing + +- [ ] **Step 12: Commit** + +```bash +git add lib/apartment/adapters/ spec/unit/adapters/ +git commit -m "Adapter interface: base_config_override keyword, drop with composite pool keys" +``` + +--- + +### Task 6: RBAC Grants — grant_privileges in Adapters + +**Files:** +- Modify: `lib/apartment/adapters/abstract_adapter.rb:39-51` (create method) +- Modify: `lib/apartment/adapters/postgresql_schema_adapter.rb` +- Modify: `lib/apartment/adapters/mysql2_adapter.rb` +- Test: `spec/unit/adapters/abstract_adapter_spec.rb` (add cases) +- Test: `spec/unit/adapters/postgresql_schema_adapter_spec.rb` (add cases) +- Test: `spec/unit/adapters/mysql2_adapter_spec.rb` (add cases) + +- [ ] **Step 1: Write failing test for grant dispatch in AbstractAdapter** + +In `spec/unit/adapters/abstract_adapter_spec.rb`, add: + +```ruby +describe '#create with app_role' do + it 'calls grant_privileges when app_role is a string' do + reconfigure(app_role: 'app_user') + allow(adapter).to(receive(:create_tenant)) + conn = double('connection') + allow(ActiveRecord::Base).to(receive(:connection).and_return(conn)) + allow(adapter).to(receive(:grant_privileges)) + + adapter.create('tenant1') + + expect(adapter).to(have_received(:grant_privileges).with('tenant1', conn, 'app_user')) + end + + it 'calls the callable when app_role is a proc' do + grant_proc = double('callable') + allow(grant_proc).to(receive(:respond_to?).with(:call).and_return(true)) + allow(grant_proc).to(receive(:call)) + reconfigure(app_role: grant_proc) + allow(adapter).to(receive(:create_tenant)) + conn = double('connection') + allow(ActiveRecord::Base).to(receive(:connection).and_return(conn)) + + adapter.create('tenant1') + + expect(grant_proc).to(have_received(:call).with('tenant1', conn)) + end + + it 'skips grants when app_role is nil' do + reconfigure(app_role: nil) + allow(adapter).to(receive(:create_tenant)) + allow(adapter).to(receive(:grant_privileges)) + + adapter.create('tenant1') + + expect(adapter).not_to(have_received(:grant_privileges)) + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb -e 'app_role' -f doc` +Expected: FAIL + +- [ ] **Step 3: Implement grant dispatch in AbstractAdapter#create** + +In `lib/apartment/adapters/abstract_adapter.rb`, modify the `create` method to add `grant_tenant_privileges(tenant)` after `create_tenant(tenant)`: + +```ruby +def create(tenant) + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: base_config['adapter'] + ) + run_callbacks(:create) do + create_tenant(tenant) + grant_tenant_privileges(tenant) + import_schema(tenant) if Apartment.config.schema_load_strategy + seed(tenant) if Apartment.config.seed_after_create + Instrumentation.instrument(:create, tenant: tenant) + end +end +``` + +Add private methods: + +```ruby +def grant_tenant_privileges(tenant) + app_role = Apartment.config.app_role + return unless app_role + + conn = ActiveRecord::Base.connection + if app_role.respond_to?(:call) + app_role.call(tenant, conn) + else + grant_privileges(tenant, conn, app_role) + end +end + +def grant_privileges(tenant, connection, role_name) + # no-op — PG and MySQL adapters override +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb -e 'app_role' -f doc` +Expected: PASS + +- [ ] **Step 5: Write failing test for PostgresqlSchemaAdapter grants** + +In `spec/unit/adapters/postgresql_schema_adapter_spec.rb`, add: + +```ruby +describe '#grant_privileges' do + let(:conn) { double('connection') } + + before do + allow(conn).to(receive(:quote_table_name)) { |name| "\"#{name}\"" } + allow(conn).to(receive(:execute)) + end + + it 'executes 6 grant statements' do + adapter.send(:grant_privileges, 'acme', conn, 'app_user') + expect(conn).to(have_received(:execute).exactly(6).times) + end + + it 'grants USAGE ON SCHEMA' do + adapter.send(:grant_privileges, 'acme', conn, 'app_user') + expect(conn).to(have_received(:execute).with(include('GRANT USAGE ON SCHEMA'))) + end + + it 'sets ALTER DEFAULT PRIVILEGES for tables' do + adapter.send(:grant_privileges, 'acme', conn, 'app_user') + expect(conn).to(have_received(:execute).with(include('ALTER DEFAULT PRIVILEGES').and(include('TABLES')))) + end +end +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/adapters/postgresql_schema_adapter_spec.rb -e 'grant_privileges' -f doc` +Expected: FAIL — method is no-op + +- [ ] **Step 7: Implement PostgresqlSchemaAdapter#grant_privileges** + +In `lib/apartment/adapters/postgresql_schema_adapter.rb`, add after `resolve_connection_config`: + +```ruby +private + +def grant_privileges(tenant, connection, role_name) + quoted_schema = connection.quote_table_name(tenant) + quoted_role = connection.quote_table_name(role_name) + + connection.execute("GRANT USAGE ON SCHEMA #{quoted_schema} TO #{quoted_role}") + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA #{quoted_schema} TO #{quoted_role}" + ) + connection.execute( + "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA #{quoted_schema} TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT USAGE, SELECT ON SEQUENCES TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT EXECUTE ON FUNCTIONS TO #{quoted_role}" + ) +end +``` + +- [ ] **Step 8: Implement Mysql2Adapter#grant_privileges** + +In `lib/apartment/adapters/mysql2_adapter.rb`, add: + +```ruby +private + +def grant_privileges(tenant, connection, role_name) + db_name = environmentify(tenant) + quoted_role = connection.quote(role_name) + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON `#{db_name}`.* TO #{quoted_role}@'%'" + ) +end +``` + +- [ ] **Step 9: Note: PostgresqlDatabaseAdapter grants deferred** + +`PostgresqlDatabaseAdapter` inherits the no-op `grant_privileges` from `AbstractAdapter`. Database-per-tenant PG grants have a cross-database ordering issue (`GRANT CONNECT ON DATABASE` runs on the server connection, table/sequence grants run inside the tenant database). Per the design spec, the built-in default starts as a no-op; users with database-per-tenant PG + RBAC use the callable escape hatch. Add a comment in `PostgresqlDatabaseAdapter`: + +```ruby +# grant_privileges: inherits no-op from AbstractAdapter. +# Database-per-tenant RBAC grants require cross-database ordering +# (GRANT CONNECT on server, table grants inside tenant DB). +# Use the callable app_role escape hatch for this strategy. +# See docs/designs/v4-phase5-rbac-roles-schema-cache.md. +``` + +- [ ] **Step 10: Run all adapter tests** + +Run: `bundle exec rspec spec/unit/adapters/ -f doc` +Expected: PASS + +- [ ] **Step 11: Commit** + +```bash +git add lib/apartment/adapters/ spec/unit/adapters/ +git commit -m "RBAC grants: grant_privileges in PG schema (6 SQL) and MySQL (1 SQL) adapters" +``` + +--- + +### Task 7: ConnectionHandling — Role-Aware Resolution + +**Files:** +- Modify: `lib/apartment/patches/connection_handling.rb:12-48` +- Test: `spec/unit/connection_handling_role_spec.rb` (new) + +This is the core change. The existing `ConnectionHandling#connection_pool` is fully replaced. + +- [ ] **Step 1: Note on unit testing ConnectionHandling** + +`ConnectionHandling#connection_pool` is a prepended method on `ActiveRecord::Base` that calls `super` for the default pool. Unit testing it in isolation requires a full AR setup. Rather than writing a vacuous test, the role-aware behavior is verified via: +- **Adapter tests** (Task 5): `base_config_override:` works correctly +- **Integration tests** (Task 11): Full `connected_to(role:) { Tenant.switch { ... } }` flow +- **Migrator tests** (Task 8): `with_migration_role` wraps correctly + +No separate unit spec file for ConnectionHandling is created. The code is simple enough (pool key format, `super` call, delegation to adapter) that integration coverage is sufficient. + +- [ ] **Step 2: Implement role-aware ConnectionHandling** + +Replace the entire `connection_pool` method in `lib/apartment/patches/connection_handling.rb`: + +```ruby +# frozen_string_literal: true + +require 'active_record' + +module Apartment + module Patches + module ConnectionHandling + def connection_pool # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + tenant = Apartment::Current.tenant + cfg = Apartment.config + + return super if tenant.nil? || cfg.nil? + return super if tenant.to_s == cfg.default_tenant.to_s + return super unless Apartment.pool_manager + + role = ActiveRecord::Base.current_role + pool_key = "#{tenant}:#{role}" + + Apartment.pool_manager.fetch_or_create(pool_key) do + default_pool = super + base = default_pool.db_config.configuration_hash.stringify_keys + + config = Apartment.adapter.validated_connection_config(tenant, base_config_override: base) + prefix = cfg.shard_key_prefix + shard_key = :"#{prefix}_#{pool_key}" + + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + cfg.rails_env_name, + "#{prefix}_#{pool_key}", + config + ) + + pool = ActiveRecord::Base.connection_handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: role, + shard: shard_key + ) + + if check_pending_migrations?(pool) + raise(Apartment::PendingMigrationError.new(tenant)) + end + + if cfg.schema_cache_per_tenant + load_tenant_schema_cache(tenant, pool) + end + + pool + end + rescue Apartment::ApartmentError + raise + rescue StandardError => e + raise(Apartment::ApartmentError, + "Failed to resolve connection pool for tenant '#{tenant}': #{e.class}: #{e.message}") + end + + private + + def check_pending_migrations?(pool) + return false unless Apartment.config.check_pending_migrations + return false unless defined?(Rails) && Rails.env.local? + return false if Apartment::Current.migrating + + pool.migration_context.needs_migration? + end + + def load_tenant_schema_cache(tenant, pool) + require_relative '../schema_cache' + cache_path = Apartment::SchemaCache.cache_path_for(tenant) + return unless File.exist?(cache_path) + + pool.schema_cache.load!(cache_path) + end + end + end +end +``` + +- [ ] **Step 3: Run full unit suite to check for regressions** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing (existing connection_handling tests may need pool key updates) + +- [ ] **Step 4: Fix any failing tests due to pool key format change** + +Existing tests that check pool keys as `"tenant"` must update to `"tenant:writing"` (or whatever `current_role` is). Search for `pool_key` or `fetch_or_create` in specs and update as needed. + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/patches/connection_handling.rb spec/unit/connection_handling_role_spec.rb +git commit -m "Role-aware ConnectionHandling: resolve base config from current role's default pool" +``` + +--- + +### Task 8: Migrator — with_migration_role, Current.migrating, Pool Eviction + +**Files:** +- Modify: `lib/apartment/migrator.rb:40-73,117-147` +- Test: `spec/unit/migrator_spec.rb` (existing, add cases) + +- [ ] **Step 1: Write failing tests for with_migration_role** + +In `spec/unit/migrator_spec.rb`, add: + +```ruby +RSpec.describe(Apartment::Migrator, 'Phase 5: migration_role') do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + end + + describe '#with_migration_role' do + it 'yields without connected_to when migration_role is nil' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.migration_role = nil + end + migrator = described_class.new + + expect(ActiveRecord::Base).not_to(receive(:connected_to)) + migrator.send(:with_migration_role) { 'result' } + end + + it 'wraps in connected_to when migration_role is set' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.migration_role = :db_manager + end + migrator = described_class.new + + expect(ActiveRecord::Base).to(receive(:connected_to).with(role: :db_manager).and_yield) + migrator.send(:with_migration_role) { 'result' } + end + end + + describe 'Current.migrating flag' do + it 'sets Current.migrating in migrate_tenant' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + migrator = described_class.new + + migrating_during_switch = nil + allow(Apartment::Tenant).to(receive(:switch)).and_yield + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return( + double(migration_context: double(needs_migration?: false)) + )) + + migrator.send(:migrate_tenant, 'acme') + + # Verify migrating was set (check indirectly via the flag being cleared in ensure) + expect(Apartment::Current.migrating).to(be_falsey) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -e 'Phase 5' -f doc` +Expected: FAIL — `with_migration_role` not defined + +- [ ] **Step 3: Implement Migrator changes** + +In `lib/apartment/migrator.rb`, modify the `run` method to use `with_migration_role` for primary: + +Replace `migrate_primary` call (line 48) with: +```ruby +primary_result = with_migration_role { migrate_primary } +``` + +Add `ensure` block to `run` for pool eviction: + +```ruby +def run # rubocop:disable Metrics/MethodLength + start = monotonic_now + + primary_result = with_migration_role { migrate_primary } + + if primary_result.status == :failed + return MigrationRun.new( + results: [primary_result], + total_duration: monotonic_now - start, + threads: @threads + ) + end + + tenants = Apartment.config.tenants_provider.call + tenant_results = if @threads.positive? + run_parallel(tenants) + else + run_sequential(tenants) + end + + all_results = [primary_result, *tenant_results].compact + + MigrationRun.new( + results: all_results, + total_duration: monotonic_now - start, + threads: @threads + ) +ensure + evict_migration_pools +end +``` + +Wrap `migrate_tenant` with `with_migration_role` and `Current.migrating`: + +```ruby +def migrate_tenant(tenant) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + start = monotonic_now + Apartment::Current.migrating = true + + with_migration_role do + Apartment::Tenant.switch(tenant) do + context = ActiveRecord::Base.connection_pool.migration_context + + unless @version || context.needs_migration? + return Result.new( + tenant: tenant, status: :skipped, + duration: monotonic_now - start, error: nil, versions_run: [] + ) + end + + with_advisory_locks_disabled do + raw_versions = context.migrate(@version) + versions = Array(raw_versions).map { _1.respond_to?(:version) ? _1.version : _1 } + + Instrumentation.instrument(:migrate_tenant, tenant: tenant, versions: versions) + + Result.new( + tenant: tenant, status: :success, + duration: monotonic_now - start, error: nil, versions_run: versions + ) + end + end + end +rescue StandardError => e + Result.new( + tenant: tenant, status: :failed, + duration: monotonic_now - start, error: e, versions_run: [] + ) +ensure + Apartment::Current.migrating = false +end +``` + +Add private methods: + +```ruby +def with_migration_role(&) + role = Apartment.config.migration_role + role ? ActiveRecord::Base.connected_to(role: role, &) : yield +end + +def evict_migration_pools + role = Apartment.config.migration_role + return unless role && Apartment.pool_manager + + Apartment.pool_manager.evict_by_role(role).each do |pool_key, _pool| + Apartment.deregister_shard(pool_key) + end +rescue StandardError => e + warn "[Apartment::Migrator] Pool eviction failed: #{e.class}: #{e.message}" +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -f doc` +Expected: PASS + +- [ ] **Step 5: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/migrator.rb spec/unit/migrator_spec.rb +git commit -m "Migrator: with_migration_role, Current.migrating flag, post-migration pool eviction" +``` + +--- + +### Task 9: SchemaCache Module + +**Files:** +- Create: `lib/apartment/schema_cache.rb` +- Test: `spec/unit/schema_cache_spec.rb` (new) + +- [ ] **Step 1: Write failing test** + +Create `spec/unit/schema_cache_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/schema_cache' + +RSpec.describe(Apartment::SchemaCache) do + describe '.cache_path_for' do + it 'returns db/schema_cache_.yml' do + path = described_class.cache_path_for('acme') + expect(path).to(end_with('db/schema_cache_acme.yml')) + end + end + + describe '.dump' do + it 'switches to tenant and dumps schema cache' do + schema_cache = double('schema_cache') + connection = double('connection', schema_cache: schema_cache) + allow(Apartment::Tenant).to(receive(:switch).and_yield) + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(schema_cache).to(receive(:dump_to)) + + path = described_class.dump('acme') + + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + expect(schema_cache).to(have_received(:dump_to).with(path)) + expect(path).to(end_with('schema_cache_acme.yml')) + end + end + + describe '.dump_all' do + it 'dumps for each tenant from provider' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'public' + end + + allow(described_class).to(receive(:dump).and_return('path')) + + described_class.dump_all + + expect(described_class).to(have_received(:dump).with('t1')) + expect(described_class).to(have_received(:dump).with('t2')) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/schema_cache_spec.rb -f doc` +Expected: FAIL — `Apartment::SchemaCache` not defined + +- [ ] **Step 3: Implement SchemaCache module** + +Create `lib/apartment/schema_cache.rb`: + +```ruby +# frozen_string_literal: true + +require 'pathname' + +module Apartment + module SchemaCache + module_function + + def dump(tenant) + path = cache_path_for(tenant) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.schema_cache.dump_to(path) + end + path + end + + def dump_all + Apartment.config.tenants_provider.call.map { |t| dump(t) } + end + + def cache_path_for(tenant) + base = defined?(Rails) && Rails.root ? Rails.root.join('db') : Pathname.new('db') + base.join("schema_cache_#{tenant}.yml").to_s + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/schema_cache_spec.rb -f doc` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/schema_cache.rb spec/unit/schema_cache_spec.rb +git commit -m "Add Apartment::SchemaCache module for per-tenant cache generation" +``` + +--- + +### Task 10: Rake Task — apartment:schema:cache:dump + +**Files:** +- Modify: `lib/apartment/tasks/v4.rake:77` + +- [ ] **Step 1: Add the rake task** + +At the end of `lib/apartment/tasks/v4.rake` (before the final `end`), add: + +```ruby +namespace :schema do + namespace :cache do + desc 'Dump schema cache for each tenant' + task dump: :environment do + require 'apartment/schema_cache' + paths = Apartment::SchemaCache.dump_all + paths.each { |p| puts "Dumped: #{p}" } + end + end +end +``` + +- [ ] **Step 2: Verify rake task loads** + +Run: `bundle exec rake -T apartment:schema` (may require Rails context — verify in integration) + +- [ ] **Step 3: Commit** + +```bash +git add lib/apartment/tasks/v4.rake +git commit -m "Add apartment:schema:cache:dump rake task" +``` + +--- + +### Task 11: Integration Tests (requires databases) + +**Files:** +- Create: `spec/integration/v4/role_aware_connection_spec.rb` +- Create: `spec/integration/v4/pending_migration_spec.rb` + +Integration tests for RBAC grants and migrator role require PostgreSQL with actual roles configured — these should be written after the unit tests pass and validated against the CI matrix. The spec shapes are documented in the design spec (section "Integration Tests"). + +- [ ] **Step 1: Write integration test for role-aware pool resolution (SQLite)** + +Create `spec/integration/v4/role_aware_connection_spec.rb` with a basic test that verifies pool keys include the role suffix when `Tenant.switch` is used. + +- [ ] **Step 2: Write integration test for PendingMigrationError (SQLite)** + +Create `spec/integration/v4/pending_migration_spec.rb` that creates a tenant, adds a migration, and verifies the error is raised on pool creation in a local environment. + +- [ ] **Step 3: Run integration tests** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/role_aware_connection_spec.rb spec/integration/v4/pending_migration_spec.rb -f doc` + +- [ ] **Step 4: Commit** + +```bash +git add spec/integration/v4/ +git commit -m "Integration tests: role-aware connections and PendingMigrationError" +``` + +--- + +### Task 12: Final Verification + +- [ ] **Step 1: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ --format progress` +Expected: All passing + +- [ ] **Step 2: Run full unit suite across Rails versions** + +Run: `bundle exec appraisal rspec spec/unit/ --format progress` +Expected: All passing across all appraisals + +- [ ] **Step 3: Run integration suite (SQLite)** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ --format progress` + +- [ ] **Step 4: Run linter** + +Run: `bundle exec rubocop lib/apartment/ spec/unit/` +Fix any offenses. + +- [ ] **Step 5: Final commit (lint fixes if any)** + +```bash +git add -A +git commit -m "Lint fixes for Phase 5" +``` diff --git a/docs/plans/apartment-v4/phase-5.2-rbac-integration-tests.md b/docs/plans/apartment-v4/phase-5.2-rbac-integration-tests.md new file mode 100644 index 00000000..7a96cc66 --- /dev/null +++ b/docs/plans/apartment-v4/phase-5.2-rbac-integration-tests.md @@ -0,0 +1,921 @@ +# Phase 5.2: RBAC Integration Tests — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integration tests that verify Phase 5's role-aware connections, RBAC privilege grants, and Migrator migration_role against real PostgreSQL roles and MySQL users. + +**Architecture:** RbacHelper module provides engine-aware role provisioning, `connect_as` for grant tests (separate connections), and `setup_connects_to!` for role-aware routing tests (real AR role wiring). CI provisions roles via psql/mysql steps; local dev falls back to idempotent `before(:context)` hooks. All specs tagged `:rbac` + engine tag, auto-skip when roles unavailable. + +**Tech Stack:** RSpec, ActiveRecord (PostgreSQL pg adapter, mysql2 adapter), GitHub Actions CI + +**Spec:** `docs/designs/v4-phase5.2-rbac-integration-tests.md` + +--- + +## File Map + +``` +.github/workflows/ci.yml # MODIFY — add RBAC role provisioning steps +spec/integration/v4/ + support/ + rbac_helper.rb # NEW — shared RBAC test infrastructure + role_aware_connection_spec.rb # NEW — ConnectionHandling role-based pool resolution + rbac_grants_spec.rb # NEW — PG privilege boundary verification + migrator_rbac_spec.rb # NEW — Migrator with migration_role + mysql_rbac_grants_spec.rb # NEW — MySQL privilege boundary verification +``` + +--- + +### Task 1: CI Role Provisioning + +**Files:** +- Modify: `.github/workflows/ci.yml:89-102` (PG job steps), `:133-145` (MySQL job steps) + +- [ ] **Step 1: Add PostgreSQL role provisioning step** + +In `.github/workflows/ci.yml`, insert a new step in the `postgresql` job between the `ruby/setup-ruby` step and the "Run v4 integration tests" step (after line 94, before line 95): + +```yaml + - name: Provision RBAC test roles + run: | + psql -h 127.0.0.1 -U postgres -d apartment_postgresql_test <<'SQL' + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'apt_test_db_manager') THEN + CREATE ROLE apt_test_db_manager LOGIN CREATEDB; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'apt_test_app_user') THEN + CREATE ROLE apt_test_app_user LOGIN; + END IF; + END $$; + GRANT apt_test_app_user TO apt_test_db_manager; + SQL +``` + +- [ ] **Step 2: Add MySQL role provisioning step** + +In `.github/workflows/ci.yml`, insert a new step in the `mysql` job between the `ruby/setup-ruby` step and the "Run v4 integration tests" step (after line 138, before line 139): + +```yaml + - name: Provision RBAC test roles + run: | + mysql -h 127.0.0.1 -u root <<'SQL' + CREATE USER IF NOT EXISTS 'apt_test_db_manager'@'%'; + CREATE USER IF NOT EXISTS 'apt_test_app_user'@'%'; + GRANT ALL PRIVILEGES ON *.* TO 'apt_test_db_manager'@'%'; + GRANT SELECT, INSERT, UPDATE, DELETE ON `apartment\_%`.* TO 'apt_test_app_user'@'%'; + FLUSH PRIVILEGES; + SQL +``` + +Note: No `-p` flag — CI MySQL uses `MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add RBAC test role provisioning for PG and MySQL" +``` + +--- + +### Task 2: RbacHelper Module + +**Files:** +- Create: `spec/integration/v4/support/rbac_helper.rb` + +- [ ] **Step 1: Create the rbac_helper.rb file** + +```ruby +# frozen_string_literal: true + +# Shared RBAC test infrastructure for integration tests that verify +# role-aware connections, privilege grants, and Migrator migration_role. +# +# Usage: tag specs with :rbac plus :postgresql_only or :mysql_only. +# Roles are provisioned once per suite via before(:context, :rbac). +# If provisioning fails (e.g., local PG user lacks CREATEROLE), +# all :rbac specs skip with an actionable message. +module RbacHelper + ROLES = { + db_manager: 'apt_test_db_manager', + app_user: 'apt_test_app_user' + }.freeze + + @provisioned = false + @available = false + + module_function + + def provisioned? + @provisioned + end + + def available? + @available + end + + # Idempotent role creation. Engine-aware. + # Returns true on success, false on failure. + def provision_roles!(connection) + return @available if @provisioned + + @provisioned = true + engine = V4IntegrationHelper.database_engine + + case engine + when 'postgresql' + provision_pg_roles!(connection) + when 'mysql' + provision_mysql_roles!(connection) + else + warn '[RbacHelper] RBAC tests require PostgreSQL or MySQL' + return(@available = false) + end + + @available = true + rescue ActiveRecord::StatementInvalid => e + warn "[RbacHelper] Could not provision roles (#{e.class}): #{e.message}" + warn '[RbacHelper] See docs/designs/v4-phase5.2-rbac-integration-tests.md for setup instructions.' + @available = false + end + + # Connect as a specific role. Stashes the original config for restoration. + # For grant verification tests (separate connections, not SET ROLE). + def connect_as(role_key) + username = ROLES.fetch(role_key) + @stashed_config ||= ActiveRecord::Base.connection_db_config.configuration_hash.stringify_keys + ActiveRecord::Base.establish_connection(@stashed_config.merge('username' => username)) + end + + # Restore the connection stashed by connect_as. + def restore_default_connection! + return unless @stashed_config + + ActiveRecord::Base.establish_connection(@stashed_config) + @stashed_config = nil + end + + # Register database configs for :writing and :db_manager roles with AR's + # ConnectionHandler. Uses the same database but different usernames. + # Call in before(:each) — after the ConnectionHandler swap creates a fresh handler. + def setup_connects_to!(base_config) + handler = ActiveRecord::Base.connection_handler + + { writing: base_config, + db_manager: base_config.merge('username' => ROLES[:db_manager]) }.each do |role, config| + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + 'test', "primary_#{role}", config + ) + handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: role + ) + end + end + + # Disconnect and remove non-primary pools created during tests. + def teardown_rbac_connections! + @stashed_config = nil + end + + # --- Private provisioning methods --- + + def provision_pg_roles!(connection) + connection.execute(<<~SQL) + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '#{ROLES[:db_manager]}') THEN + CREATE ROLE #{ROLES[:db_manager]} LOGIN CREATEDB; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '#{ROLES[:app_user]}') THEN + CREATE ROLE #{ROLES[:app_user]} LOGIN; + END IF; + END $$; + SQL + connection.execute("GRANT #{ROLES[:app_user]} TO #{ROLES[:db_manager]}") + # GRANT CREATE ON DATABASE so db_manager can create schemas. + # This runs here (not in CI provisioning) because the test database + # (apartment_v4_test) may not exist at CI role-provisioning time. + db_name = connection.current_database + connection.execute("GRANT CREATE ON DATABASE #{connection.quote_table_name(db_name)} TO #{ROLES[:db_manager]}") + end + + def provision_mysql_roles!(connection) + connection.execute("CREATE USER IF NOT EXISTS '#{ROLES[:db_manager]}'@'%'") + connection.execute("CREATE USER IF NOT EXISTS '#{ROLES[:app_user]}'@'%'") + connection.execute("GRANT ALL PRIVILEGES ON *.* TO '#{ROLES[:db_manager]}'@'%'") + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON `apartment\\_%`.* TO '#{ROLES[:app_user]}'@'%'" + ) + connection.execute('FLUSH PRIVILEGES') + end + + private_class_method :provision_pg_roles!, :provision_mysql_roles! +end + +# Wire up the :rbac tag to provision roles once per context. +if V4_INTEGRATION_AVAILABLE + RSpec.configure do |config| + config.before(:context, :rbac) do + V4IntegrationHelper.ensure_test_database! + V4IntegrationHelper.establish_default_connection! + + unless RbacHelper.provision_roles!(ActiveRecord::Base.connection) + skip 'RBAC test roles not available. See docs/designs/v4-phase5.2-rbac-integration-tests.md' + end + end + end +end +``` + +- [ ] **Step 2: Verify file loads without error** + +```bash +bundle exec ruby -e " + require 'active_record' + V4_INTEGRATION_AVAILABLE = true + require_relative 'spec/integration/v4/support.rb' + require_relative 'spec/integration/v4/support/rbac_helper.rb' + puts 'RbacHelper loaded: ' + RbacHelper::ROLES.inspect +" +``` + +Expected: `RbacHelper loaded: {:db_manager=>"apt_test_db_manager", :app_user=>"apt_test_app_user"}` + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/support/rbac_helper.rb +git commit -m "Add RbacHelper module for RBAC integration tests + +Engine-aware role provisioning (PG CREATE ROLE, MySQL CREATE USER), +connect_as/restore for grant tests, setup_connects_to! for role-aware +routing tests. Auto-skip with actionable message when roles unavailable." +``` + +--- + +### Task 3: Role-Aware Connection Spec (PostgreSQL) + +**Files:** +- Create: `spec/integration/v4/role_aware_connection_spec.rb` + +**References:** +- `lib/apartment/patches/connection_handling.rb` — the code under test +- `spec/integration/v4/support.rb` — V4IntegrationHelper patterns +- `spec/integration/v4/support/rbac_helper.rb` — RbacHelper (Task 2) + +- [ ] **Step 1: Write the spec file** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' + +RSpec.describe 'Role-aware connection routing', :integration, :rbac, :postgresql_only, + skip: (!V4_INTEGRATION_AVAILABLE || V4IntegrationHelper.database_engine != 'postgresql') && 'requires PostgreSQL' do + include V4IntegrationHelper + + let(:tenant) { 'rbac_conn_tenant' } + + before do + config = V4IntegrationHelper.establish_default_connection! + RbacHelper.setup_connects_to!(config) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [tenant] } + c.default_tenant = 'public' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + Apartment.adapter.create(tenant) + end + + after do + V4IntegrationHelper.cleanup_tenants!([tenant], Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + RbacHelper.teardown_rbac_connections! + end + + it 'creates separate pools per role for the same tenant' do + writing_pool = nil + writing_user = nil + + Apartment::Tenant.switch(tenant) do + writing_pool = ActiveRecord::Base.connection_pool + writing_user = ActiveRecord::Base.connection.execute('SELECT current_user AS cu').first['cu'] + end + + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) do + mgr_pool = ActiveRecord::Base.connection_pool + mgr_user = ActiveRecord::Base.connection.execute('SELECT current_user AS cu').first['cu'] + + expect(mgr_pool).not_to eq(writing_pool) + expect(mgr_user).to eq(RbacHelper::ROLES[:db_manager]) + expect(writing_user).not_to eq(mgr_user) + end + end + end + + it 'uses distinct pool keys per role' do + Apartment::Tenant.switch(tenant) { ActiveRecord::Base.connection } + + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) { ActiveRecord::Base.connection } + end + + pool_keys = Apartment.pool_manager.stats[:tenants] + expect(pool_keys).to include("#{tenant}:writing") + expect(pool_keys).to include("#{tenant}:db_manager") + end + + it 'propagates the db_manager username into tenant pool config' do + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) do + pool_config = ActiveRecord::Base.connection_pool.db_config.configuration_hash + expect(pool_config[:username]).to eq(RbacHelper::ROLES[:db_manager]) + end + end + end +end +``` + +- [ ] **Step 2: Run the spec to verify it passes** + +```bash +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/role_aware_connection_spec.rb --format documentation +``` + +Expected: 3 examples, 0 failures. If roles aren't provisioned locally, expect 3 examples skipped. + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/role_aware_connection_spec.rb +git commit -m "Add role-aware connection integration tests + +Verify ConnectionHandling creates separate pools per role for the +same tenant, with distinct pool keys and correct username propagation +from the active connected_to role's base config." +``` + +--- + +### Task 4: RBAC Grants Spec (PostgreSQL) + +**Files:** +- Create: `spec/integration/v4/rbac_grants_spec.rb` + +**References:** +- `lib/apartment/adapters/postgresql_schema_adapter.rb:37-60` — `grant_privileges` implementation +- `lib/apartment/adapters/abstract_adapter.rb` — `grant_tenant_privileges` dispatch + +- [ ] **Step 1: Write the spec file** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' + +RSpec.describe 'PostgreSQL RBAC privilege grants', :integration, :rbac, :postgresql_only, + skip: (!V4_INTEGRATION_AVAILABLE || V4IntegrationHelper.database_engine != 'postgresql') && 'requires PostgreSQL' do + include V4IntegrationHelper + + let(:tenant) { 'rbac_grants_tenant' } + + before do + config = V4IntegrationHelper.establish_default_connection! + + # Create tenant as db_manager (owns the schema) with app_role grants + RbacHelper.connect_as(:db_manager) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [tenant] } + c.default_tenant = 'public' + c.app_role = RbacHelper::ROLES[:app_user] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter( + config.merge('username' => RbacHelper::ROLES[:db_manager]) + ) + Apartment.activate! + Apartment.adapter.create(tenant) + + # Create a test table as db_manager (inside the tenant schema) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute(<<~SQL) + CREATE TABLE #{ActiveRecord::Base.connection.quote_table_name(tenant)}.widgets ( + id serial PRIMARY KEY, + name varchar(255) + ) + SQL + end + + RbacHelper.restore_default_connection! + end + + after do + # Reconnect as default (superuser) to drop + V4IntegrationHelper.establish_default_connection! + Apartment.adapter = V4IntegrationHelper.build_adapter( + V4IntegrationHelper.default_connection_config + ) + V4IntegrationHelper.cleanup_tenants!([tenant], Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + RbacHelper.teardown_rbac_connections! + end + + context 'as app_user' do + before { RbacHelper.connect_as(:app_user) } + after { RbacHelper.restore_default_connection! } + + it 'can SELECT, INSERT, UPDATE, DELETE' do + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO #{conn.quote_table_name(tenant)}.widgets (name) VALUES ('test')") + + result = conn.execute("SELECT name FROM #{conn.quote_table_name(tenant)}.widgets") + expect(result.first['name']).to eq('test') + + conn.execute("UPDATE #{conn.quote_table_name(tenant)}.widgets SET name = 'updated'") + + result = conn.execute("SELECT name FROM #{conn.quote_table_name(tenant)}.widgets") + expect(result.first['name']).to eq('updated') + + conn.execute("DELETE FROM #{conn.quote_table_name(tenant)}.widgets") + result = conn.execute("SELECT count(*) AS c FROM #{conn.quote_table_name(tenant)}.widgets") + expect(result.first['c'].to_i).to eq(0) + end + + it 'cannot CREATE TABLE in the tenant schema' do + expect { + ActiveRecord::Base.connection.execute( + "CREATE TABLE #{ActiveRecord::Base.connection.quote_table_name(tenant)}.forbidden (id serial)" + ) + }.to raise_error(ActiveRecord::StatementInvalid, /permission denied/) + end + + it 'cannot DROP SCHEMA' do + expect { + ActiveRecord::Base.connection.execute( + "DROP SCHEMA #{ActiveRecord::Base.connection.quote_table_name(tenant)} CASCADE" + ) + }.to raise_error(ActiveRecord::StatementInvalid, /must be owner|permission denied/) + end + end + + context 'ALTER DEFAULT PRIVILEGES' do + it 'grants DML on tables created after initial tenant creation' do + # As db_manager: create a new table after the tenant was created + RbacHelper.connect_as(:db_manager) + ActiveRecord::Base.connection.execute(<<~SQL) + CREATE TABLE #{ActiveRecord::Base.connection.quote_table_name(tenant)}.gadgets ( + id serial PRIMARY KEY, + label varchar(255) + ) + SQL + RbacHelper.restore_default_connection! + + # As app_user: verify DML works on the new table + RbacHelper.connect_as(:app_user) + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO #{conn.quote_table_name(tenant)}.gadgets (label) VALUES ('shiny')") + result = conn.execute("SELECT label FROM #{conn.quote_table_name(tenant)}.gadgets") + expect(result.first['label']).to eq('shiny') + RbacHelper.restore_default_connection! + end + end + + context 'as db_manager' do + before { RbacHelper.connect_as(:db_manager) } + after { RbacHelper.restore_default_connection! } + + it 'can CREATE TABLE and DROP SCHEMA' do + conn = ActiveRecord::Base.connection + conn.execute( + "CREATE TABLE #{conn.quote_table_name(tenant)}.temp_table (id serial PRIMARY KEY)" + ) + conn.execute("DROP TABLE #{conn.quote_table_name(tenant)}.temp_table") + # Verify full DDL: db_manager can drop the schema it owns + conn.execute("DROP SCHEMA #{conn.quote_table_name(tenant)} CASCADE") + # Recreate for cleanup consistency + conn.execute("CREATE SCHEMA #{conn.quote_table_name(tenant)}") + end + end +end +``` + +- [ ] **Step 2: Run the spec** + +```bash +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/rbac_grants_spec.rb --format documentation +``` + +Expected: 4 examples, 0 failures (or all skipped if roles not provisioned). + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/rbac_grants_spec.rb +git commit -m "Add RBAC privilege grant integration tests (PostgreSQL) + +Verify app_user can DML but not DDL in tenant schemas. Verify ALTER +DEFAULT PRIVILEGES fire for tables created after initial grants. +Verify db_manager retains full DDL privileges." +``` + +--- + +### Task 5: Migrator RBAC Spec (PostgreSQL) + +**Files:** +- Create: `spec/integration/v4/migrator_rbac_spec.rb` + +**References:** +- `lib/apartment/migrator.rb` — `with_migration_role`, `evict_migration_pools` +- `lib/apartment/patches/connection_handling.rb` — role-aware pool creation + +- [ ] **Step 1: Write the spec file** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' +require 'apartment/migrator' + +RSpec.describe 'Migrator with migration_role', :integration, :rbac, :postgresql_only, + skip: (!V4_INTEGRATION_AVAILABLE || V4IntegrationHelper.database_engine != 'postgresql') && 'requires PostgreSQL' do + include V4IntegrationHelper + + let(:tenants) { %w[rbac_mig_one rbac_mig_two] } + let(:migration_dir) { Dir.mktmpdir('apartment_rbac_migrations') } + + before do + config = V4IntegrationHelper.establish_default_connection! + RbacHelper.setup_connects_to!(config) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { tenants } + c.default_tenant = 'public' + c.migration_role = :db_manager + c.app_role = RbacHelper::ROLES[:app_user] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + # Create tenants as db_manager (so db_manager owns the schemas) + ActiveRecord::Base.connected_to(role: :db_manager) do + tenants.each { |t| Apartment.adapter.create(t) } + end + + # Write a real migration file + timestamp = '20260401000001' + File.write(File.join(migration_dir, "#{timestamp}_create_rbac_test_widgets.rb"), <<~RUBY) + class CreateRbacTestWidgets < ActiveRecord::Migration[7.2] + def change + create_table :rbac_test_widgets do |t| + t.string :name + end + end + end + RUBY + + # Point AR's migration context at our temp directory. + # ActiveRecord::Migrator.migrations_paths is what connection_pool.migration_context reads. + @original_migrations_paths = ActiveRecord::Migrator.migrations_paths + ActiveRecord::Migrator.migrations_paths = [migration_dir] + end + + after do + # Restore migration paths + ActiveRecord::Migrator.migrations_paths = @original_migrations_paths + + V4IntegrationHelper.establish_default_connection! + Apartment.adapter = V4IntegrationHelper.build_adapter( + V4IntegrationHelper.default_connection_config + ) + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + RbacHelper.teardown_rbac_connections! + FileUtils.rm_rf(migration_dir) + end + + it 'runs migrations as db_manager (table owned by db_manager)' do + migrator = Apartment::Migrator.new(threads: 0) + result = migrator.run + + expect(result).to be_success + + tenants.each do |t| + Apartment::Tenant.switch(t) do + owner = ActiveRecord::Base.connection.execute(<<~SQL).first['tableowner'] + SELECT tableowner FROM pg_tables + WHERE schemaname = '#{t}' AND tablename = 'rbac_test_widgets' + SQL + expect(owner).to eq(RbacHelper::ROLES[:db_manager]) + end + end + end + + it 'app_user can DML on migrated tables via default privileges' do + Apartment::Migrator.new(threads: 0).run + + RbacHelper.connect_as(:app_user) + conn = ActiveRecord::Base.connection + + tenants.each do |t| + conn.execute("INSERT INTO #{conn.quote_table_name(t)}.rbac_test_widgets (name) VALUES ('test')") + result = conn.execute("SELECT name FROM #{conn.quote_table_name(t)}.rbac_test_widgets") + expect(result.first['name']).to eq('test') + end + + RbacHelper.restore_default_connection! + end + + it 'evicts migration-role pools after run' do + Apartment::Migrator.new(threads: 0).run + + db_mgr_keys = Apartment.pool_manager.stats[:tenants].select { |k| k.end_with?(':db_manager') } + expect(db_mgr_keys).to be_empty + end + + context 'with parallel threads' do + it 'each thread uses db_manager credentials' do + migrator = Apartment::Migrator.new(threads: 2) + result = migrator.run + + expect(result).to be_success + + tenants.each do |t| + Apartment::Tenant.switch(t) do + owner = ActiveRecord::Base.connection.execute(<<~SQL).first['tableowner'] + SELECT tableowner FROM pg_tables + WHERE schemaname = '#{t}' AND tablename = 'rbac_test_widgets' + SQL + expect(owner).to eq(RbacHelper::ROLES[:db_manager]) + end + end + end + end +end +``` + +- [ ] **Step 2: Run the spec** + +```bash +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/migrator_rbac_spec.rb --format documentation +``` + +Expected: 4 examples, 0 failures (or all skipped if roles not provisioned). + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/migrator_rbac_spec.rb +git commit -m "Add Migrator RBAC integration tests (PostgreSQL) + +Verify Migrator with migration_role: :db_manager uses elevated +credentials (table ownership check), app_user gets DML via default +privileges, migration-role pools evicted after run, and parallel +threads each use db_manager credentials." +``` + +--- + +### Task 6: MySQL RBAC Grants Spec + +**Files:** +- Create: `spec/integration/v4/mysql_rbac_grants_spec.rb` + +**References:** +- `lib/apartment/adapters/mysql2_adapter.rb:34-39` — `grant_privileges` implementation + +- [ ] **Step 1: Write the spec file** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' + +RSpec.describe 'MySQL RBAC privilege grants', :integration, :rbac, :mysql_only, + skip: (!V4_INTEGRATION_AVAILABLE || V4IntegrationHelper.database_engine != 'mysql') && 'requires MySQL' do + include V4IntegrationHelper + + let(:tenant) { 'rbac_grants_tenant' } + + before do + config = V4IntegrationHelper.establish_default_connection! + + # Create tenant as db_manager with app_role grants + RbacHelper.connect_as(:db_manager) + + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [tenant] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.app_role = RbacHelper::ROLES[:app_user] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter( + config.merge('username' => RbacHelper::ROLES[:db_manager]) + ) + Apartment.activate! + Apartment.adapter.create(tenant) + + # Create a test table inside the tenant database + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute(<<~SQL) + CREATE TABLE widgets ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) + ) + SQL + end + + RbacHelper.restore_default_connection! + end + + after do + V4IntegrationHelper.establish_default_connection! + Apartment.adapter = V4IntegrationHelper.build_adapter( + V4IntegrationHelper.default_connection_config + ) + V4IntegrationHelper.cleanup_tenants!([tenant], Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + RbacHelper.teardown_rbac_connections! + end + + context 'as app_user' do + # Resolve the environmentified database name for SQL queries + let(:db_name) { Apartment.adapter.environmentify(tenant) } + + before { RbacHelper.connect_as(:app_user) } + after { RbacHelper.restore_default_connection! } + + it 'can SELECT, INSERT, UPDATE, DELETE' do + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO `#{db_name}`.widgets (name) VALUES ('test')") + + result = conn.execute("SELECT name FROM `#{db_name}`.widgets") + expect(result.first['name']).to eq('test') + + conn.execute("UPDATE `#{db_name}`.widgets SET name = 'updated'") + conn.execute("DELETE FROM `#{db_name}`.widgets") + end + + it 'cannot CREATE TABLE in the tenant database' do + expect { + ActiveRecord::Base.connection.execute( + "CREATE TABLE `#{db_name}`.forbidden (id INT PRIMARY KEY)" + ) + }.to raise_error(ActiveRecord::StatementInvalid, /command denied|Access denied/) + end + + it 'cannot DROP DATABASE' do + expect { + ActiveRecord::Base.connection.execute("DROP DATABASE `#{db_name}`") + }.to raise_error(ActiveRecord::StatementInvalid, /command denied|Access denied/) + end + end + + context 'as db_manager' do + let(:db_name) { Apartment.adapter.environmentify(tenant) } + + before { RbacHelper.connect_as(:db_manager) } + after { RbacHelper.restore_default_connection! } + + it 'can CREATE TABLE and DROP it' do + conn = ActiveRecord::Base.connection + conn.execute("CREATE TABLE `#{db_name}`.temp_table (id INT PRIMARY KEY)") + conn.execute("DROP TABLE `#{db_name}`.temp_table") + end + end +end +``` + +- [ ] **Step 2: Run the spec** + +```bash +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/mysql_rbac_grants_spec.rb --format documentation +``` + +Expected: 4 examples, 0 failures (or all skipped if roles not provisioned). + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/mysql_rbac_grants_spec.rb +git commit -m "Add RBAC privilege grant integration tests (MySQL) + +Verify app_user can DML but not CREATE TABLE or DROP DATABASE. +Verify db_manager retains full DDL privileges." +``` + +--- + +### Task 7: Full Suite Verification + +- [ ] **Step 1: Run all RBAC specs (PostgreSQL)** + +```bash +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ --tag rbac --format documentation +``` + +Expected: 11 PG examples pass (3 connection + 4 grants + 4 migrator). + +- [ ] **Step 2: Run all RBAC specs (MySQL)** + +```bash +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ --tag rbac --format documentation +``` + +Expected: 4 MySQL examples pass. + +- [ ] **Step 3: Run full integration suite to verify no regressions** + +```bash +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ --format progress +``` + +Expected: All existing specs still pass. RBAC specs either pass (roles provisioned) or skip (roles not provisioned). + +- [ ] **Step 4: Run unit tests to verify no regressions** + +```bash +bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/ --format progress +``` + +Expected: All unit tests pass. + +- [ ] **Step 5: Run Rubocop on new files** + +```bash +bundle exec rubocop spec/integration/v4/support/rbac_helper.rb spec/integration/v4/role_aware_connection_spec.rb spec/integration/v4/rbac_grants_spec.rb spec/integration/v4/migrator_rbac_spec.rb spec/integration/v4/mysql_rbac_grants_spec.rb +``` + +Expected: No offenses. Fix any that appear. + +- [ ] **Step 6: Final commit (if any fixups needed)** + +Only if Steps 1-5 revealed issues that needed fixes. + +--- + +### Task 8: Update CLAUDE.md and Spec Documentation + +**Files:** +- Modify: `spec/CLAUDE.md` — add RBAC integration test documentation +- Modify: `CLAUDE.md` — update test commands section + +- [ ] **Step 1: Update spec/CLAUDE.md** + +Add to the "Test Coverage" section under existing coverage areas: + +```markdown +- ✅ RBAC integration (role-aware connections, privilege grants, Migrator with migration_role) +``` + +Add a brief section under "Integration Tests" describing the RBAC test files and how to run them. + +- [ ] **Step 2: Update CLAUDE.md Commands section** + +Add the RBAC test commands: + +```bash +# RBAC integration tests (requires PostgreSQL with provisioned roles) +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ --tag rbac + +# MySQL RBAC tests +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ --tag rbac +``` + +- [ ] **Step 3: Commit** + +```bash +git add spec/CLAUDE.md CLAUDE.md +git commit -m "Update docs with RBAC integration test instructions" +``` diff --git a/docs/plans/apartment-v4/phase-6-cli-generator.md b/docs/plans/apartment-v4/phase-6-cli-generator.md new file mode 100644 index 00000000..c503884a --- /dev/null +++ b/docs/plans/apartment-v4/phase-6-cli-generator.md @@ -0,0 +1,1898 @@ +# Phase 6: CLI & Generator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace inline rake task logic with a Thor CLI as the primary interface, refactor rake tasks to thin wrappers, rewrite the install generator for v4. + +**Architecture:** Per-file Thor subcommands registered under `Apartment::CLI`. Each subcommand class handles one domain (tenants, migrations, seeds, pool). Rake tasks delegate to CLI classes. Two new public APIs on existing classes: `Migrator#migrate_one` and `PoolReaper#run_cycle`. + +**Tech Stack:** Thor >= 1.3.0 (already a dependency), RSpec, Rails generators + +**Design spec:** `docs/designs/v4-phase6-cli-generator.md` + +--- + +## File Map + +### New files +| File | Responsibility | +|------|---------------| +| `lib/apartment/cli.rb` | Entry point: registers subcommands | +| `lib/apartment/cli/tenants.rb` | `create`, `drop`, `list`, `current` commands | +| `lib/apartment/cli/migrations.rb` | `migrate`, `rollback` commands | +| `lib/apartment/cli/seeds.rb` | `seed` command | +| `lib/apartment/cli/pool.rb` | `stats`, `evict` commands | +| `lib/generators/apartment/install/templates/binstub` | `bin/apartment` template | +| `spec/unit/cli_spec.rb` | Subcommand registration tests | +| `spec/unit/cli/tenants_spec.rb` | Tenants CLI tests | +| `spec/unit/cli/migrations_spec.rb` | Migrations CLI tests | +| `spec/unit/cli/seeds_spec.rb` | Seeds CLI tests | +| `spec/unit/cli/pool_spec.rb` | Pool CLI tests | +| `spec/unit/generator/install_generator_spec.rb` | Generator tests | + +### Modified files +| File | Change | +|------|--------| +| `lib/apartment/migrator.rb` | Add public `migrate_one(tenant)` method | +| `lib/apartment/pool_reaper.rb` | Add public `run_cycle` method, make `reap` delegate | +| `lib/apartment/tasks/v4.rake` | Replace inline logic with CLI delegation | +| `lib/generators/apartment/install/install_generator.rb` | Add binstub generation | +| `lib/generators/apartment/install/templates/apartment.rb` | Rewrite for v4 Config | +| `spec/unit/migrator_spec.rb` | Add `migrate_one` tests | +| `spec/unit/pool_reaper_spec.rb` | Add `run_cycle` tests | + +--- + +## Phase 6.1: Thor CLI + Rake Refactor + +### Task 1: `Migrator#migrate_one` — failing test + +**Files:** +- Test: `spec/unit/migrator_spec.rb` + +- [ ] **Step 1: Write the failing test for `migrate_one`** + +Add to the end of `spec/unit/migrator_spec.rb`, before the final `end`: + +```ruby +describe '#migrate_one' do + let(:migrator) { described_class.new } + let(:mock_migration_context) { instance_double('ActiveRecord::MigrationContext') } + let(:mock_pool) { instance_double('ActiveRecord::ConnectionAdapters::ConnectionPool') } + let(:mock_connection) { double('connection') } + + before do + allow(ActiveRecord::Base).to(receive_messages(connection_pool: mock_pool, lease_connection: mock_connection)) + allow(mock_connection).to(receive(:instance_variable_get).and_return(true)) + allow(mock_connection).to(receive(:instance_variable_set)) + allow(mock_pool).to(receive(:migration_context).and_return(mock_migration_context)) + allow(mock_migration_context).to(receive_messages(needs_migration?: true, migrate: [])) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(Apartment::Tenant).to(receive(:switch)) { |_tenant, &block| block.call } + end + + it 'returns a single Result for the given tenant' do + result = migrator.migrate_one('acme') + expect(result).to(be_a(Apartment::Migrator::Result)) + expect(result.tenant).to(eq('acme')) + expect(result.status).to(eq(:success)) + end + + it 'switches to the given tenant' do + migrator.migrate_one('acme') + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + end + + it 'sets Current.migrating during execution' do + migrating_during = nil + allow(Apartment::Tenant).to(receive(:switch)) do |&block| + migrating_during = Apartment::Current.migrating + block&.call + end + migrator.migrate_one('acme') + expect(migrating_during).to(be(true)) + end + + it 'clears Current.migrating after completion' do + migrator.migrate_one('acme') + expect(Apartment::Current.migrating).to(be_falsey) + end + + it 'disables advisory locks' do + migrator.migrate_one('acme') + expect(mock_connection).to(have_received(:instance_variable_set) + .with(:@advisory_locks_enabled, false)) + end + + it 'instruments the migration' do + migrator.migrate_one('acme') + expect(Apartment::Instrumentation).to(have_received(:instrument) + .with(:migrate_tenant, hash_including(tenant: 'acme'))) + end + + it 'returns :skipped when no pending migrations' do + allow(mock_migration_context).to(receive(:needs_migration?).and_return(false)) + result = migrator.migrate_one('acme') + expect(result.status).to(eq(:skipped)) + end + + it 'captures errors and returns :failed' do + allow(mock_migration_context).to(receive(:migrate).and_raise(StandardError, 'boom')) + result = migrator.migrate_one('acme') + expect(result.status).to(eq(:failed)) + expect(result.error.message).to(eq('boom')) + end + + it 'respects version parameter' do + migrator = described_class.new(version: 20_260_401_000_000) + expect(mock_migration_context).to(receive(:migrate).with(20_260_401_000_000).and_return([])) + migrator.migrate_one('acme') + end + + it 'calls evict_migration_pools in ensure' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.migration_role = :db_manager + end + pool_manager = instance_double(Apartment::PoolManager) + allow(Apartment).to(receive(:pool_manager).and_return(pool_manager)) + allow(pool_manager).to(receive(:evict_by_role).and_return([])) + + migrator = described_class.new + migrator.migrate_one('acme') + + expect(pool_manager).to(have_received(:evict_by_role).with(:db_manager)) + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -e 'migrate_one' --format documentation` +Expected: FAIL with `NoMethodError: undefined method 'migrate_one'` + +--- + +### Task 2: `Migrator#migrate_one` — implementation + +**Files:** +- Modify: `lib/apartment/migrator.rb:40-43` + +- [ ] **Step 3: Add `migrate_one` public method** + +Insert after the `run` method (after line 74 in `lib/apartment/migrator.rb`), before the `private` keyword: + +```ruby + # Migrate a single named tenant. Delegates to the private migrate_tenant, + # which already handles with_migration_role, advisory lock disabling, + # Current.migrating flag, and instrumentation. Returns a single Result. + def migrate_one(tenant) + migrate_tenant(tenant) + ensure + evict_migration_pools + end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -e 'migrate_one' --format documentation` +Expected: All 10 examples PASS + +- [ ] **Step 5: Run full migrator spec to verify no regressions** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb --format documentation` +Expected: All existing tests still pass + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/migrator.rb spec/unit/migrator_spec.rb +git commit -m "Add Migrator#migrate_one for single-tenant CLI migration + +Public API that delegates to the private migrate_tenant (which already +wraps with_migration_role, disables advisory locks, sets Current.migrating, +and instruments). Calls evict_migration_pools in ensure to match the +cleanup behavior of Migrator#run. Used by CLI migrations command." +``` + +--- + +### Task 3: `PoolReaper#run_cycle` — failing test + +**Files:** +- Test: `spec/unit/pool_reaper_spec.rb` + +- [ ] **Step 7: Write the failing test for `run_cycle`** + +Add before the final `end` in `spec/unit/pool_reaper_spec.rb`: + +```ruby +describe '#run_cycle' do + it 'performs one synchronous eviction pass and returns eviction count' do + pool_manager.fetch_or_create('stale_a') { 'pool_a' } + pool_manager.fetch_or_create('stale_b') { 'pool_b' } + pool_manager.instance_variable_get(:@timestamps)['stale_a'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + pool_manager.instance_variable_get(:@timestamps)['stale_b'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + pool_manager.fetch_or_create('fresh') { 'pool_fresh' } + + count = reaper.run_cycle + expect(count).to(eq(2)) + expect(pool_manager.tracked?('stale_a')).to(be(false)) + expect(pool_manager.tracked?('stale_b')).to(be(false)) + expect(pool_manager.tracked?('fresh')).to(be(true)) + end + + it 'returns 0 when nothing to evict' do + pool_manager.fetch_or_create('fresh') { 'pool_fresh' } + count = reaper.run_cycle + expect(count).to(eq(0)) + end + + it 'does not require the background timer to be running' do + expect(reaper).not_to(be_running) + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + count = reaper.run_cycle + expect(count).to(eq(1)) + end + + it 'respects default_tenant protection' do + protected_reaper = described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + default_tenant: 'public', + on_evict: on_evict + ) + pool_manager.fetch_or_create('public') { 'pool_default' } + pool_manager.instance_variable_get(:@timestamps)['public'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 9999 + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + count = protected_reaper.run_cycle + expect(count).to(eq(1)) + expect(pool_manager.tracked?('public')).to(be(true)) + expect(pool_manager.tracked?('stale')).to(be(false)) + end +end +``` + +- [ ] **Step 8: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb -e 'run_cycle' --format documentation` +Expected: FAIL with `NoMethodError: undefined method 'run_cycle'` + +--- + +### Task 4: `PoolReaper#run_cycle` — implementation + +**Files:** +- Modify: `lib/apartment/pool_reaper.rb:48-67` + +- [ ] **Step 9: Add `run_cycle` public method and refactor `reap`** + +In `lib/apartment/pool_reaper.rb`, add `run_cycle` as a public method before the `private` keyword (line 49), and refactor the private `reap` to delegate: + +Replace the section from `private` (line 49) through the end of the `reap` method (line 67): + +```ruby + # Perform one synchronous eviction pass (idle + LRU). + # Returns the total number of pools evicted. + # Called by the background timer and by CLI `pool evict`. + def run_cycle + count = 0 + count += evict_idle + count += evict_lru if @max_total + count + rescue Apartment::ApartmentError => e + warn "[Apartment::PoolReaper] #{e.class}: #{e.message}" + 0 + rescue StandardError => e + warn "[Apartment::PoolReaper] Unexpected error: #{e.class}: #{e.message}" + warn e.backtrace&.first(5)&.join("\n") if e.backtrace + 0 + end + + private + + def reap + run_cycle + end +``` + +Then update `evict_idle` to return a count. Replace the existing `evict_idle` method: + +```ruby + def evict_idle + count = 0 + @pool_manager.idle_tenants(timeout: @idle_timeout).each do |tenant| + next if default_tenant_pool?(tenant) + + pool = @pool_manager.remove(tenant) + deregister_from_ar_handler(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :idle) + @on_evict&.call(tenant, pool) + count += 1 + rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}" + end + count + end +``` + +And update `evict_lru` to return a count. Replace the existing `evict_lru` method: + +```ruby + def evict_lru + excess = @pool_manager.stats[:total_pools] - @max_total + return 0 if excess <= 0 + + candidates = @pool_manager.lru_tenants(count: excess + 1) + evicted = 0 + candidates.each do |tenant| + break if evicted >= excess + next if default_tenant_pool?(tenant) + + pool = @pool_manager.remove(tenant) + deregister_from_ar_handler(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :lru) + @on_evict&.call(tenant, pool) + evicted += 1 + rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}" + end + evicted + end +``` + +- [ ] **Step 10: Run `run_cycle` tests** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb -e 'run_cycle' --format documentation` +Expected: All 4 examples PASS + +- [ ] **Step 11: Run full pool_reaper spec to verify no regressions** + +Run: `bundle exec rspec spec/unit/pool_reaper_spec.rb --format documentation` +Expected: All existing tests still pass (idle eviction, LRU eviction, protected tenants, error resilience, instrumentation) + +- [ ] **Step 12: Commit** + +```bash +git add lib/apartment/pool_reaper.rb spec/unit/pool_reaper_spec.rb +git commit -m "Add PoolReaper#run_cycle for synchronous eviction + +Public method that performs one idle + LRU eviction pass and returns +the count of evicted pools. The background timer's private #reap +now delegates to run_cycle. Used by CLI pool evict command." +``` + +--- + +### Task 5: Zeitwerk ignore for CLI files + +**Files:** +- Modify: `lib/apartment.rb` + +Both `cli.rb` and `cli/` must be ignored from Zeitwerk. Without ignoring `cli.rb`, Zeitwerk's default inflector maps it to `Apartment::Cli` (lowercase), not `Apartment::CLI`. The subcommand files in `cli/` depend on Thor being required first. + +- [ ] **Step 13: Add Zeitwerk ignore for cli.rb and cli/ directory** + +In `lib/apartment.rb`, add after line 19 (`loader.ignore("#{__dir__}/apartment/tasks")`): + +```ruby +# CLI is loaded explicitly (require 'apartment/cli') by rake tasks and the binstub. +# Ignoring cli.rb avoids Zeitwerk mapping it to Apartment::Cli (wrong casing). +# Ignoring cli/ avoids autoloading Thor subcommands before Thor is required. +loader.ignore("#{__dir__}/apartment/cli.rb") +loader.ignore("#{__dir__}/apartment/cli") +``` + +- [ ] **Step 14: Commit** + +```bash +git add lib/apartment.rb +git commit -m "Ignore cli.rb and cli/ from Zeitwerk autoloading + +cli.rb must be ignored because Zeitwerk's default inflector maps it +to Apartment::Cli, not Apartment::CLI. The cli/ directory is ignored +because Thor subcommands depend on Thor being required first. Both +are loaded explicitly via require 'apartment/cli'." +``` + +--- + +### Task 6: `Apartment::CLI` entry point — test + +**Files:** +- Create: `spec/unit/cli_spec.rb` + +- [ ] **Step 15: Write the CLI registration test** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI) do + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to(be(true)) + end + end + + describe 'subcommand registration' do + it 'registers tenants subcommand' do + expect(help_output).to(include('tenants')) + end + + it 'registers migrations subcommand' do + expect(help_output).to(include('migrations')) + end + + it 'registers seeds subcommand' do + expect(help_output).to(include('seeds')) + end + + it 'registers pool subcommand' do + expect(help_output).to(include('pool')) + end + end + + private + + def help_output + @help_output ||= capture_stdout { described_class.start(['help']) } + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end +``` + +- [ ] **Step 16: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/cli_spec.rb --format documentation` +Expected: FAIL with `LoadError: cannot load such file -- .../apartment/cli` + +--- + +### Task 7: `Apartment::CLI` entry point — implementation + +**Files:** +- Create: `lib/apartment/cli.rb` +- Create: `lib/apartment/cli/tenants.rb` (stub) +- Create: `lib/apartment/cli/migrations.rb` (stub) +- Create: `lib/apartment/cli/seeds.rb` (stub) +- Create: `lib/apartment/cli/pool.rb` (stub) + +- [ ] **Step 17: Create the CLI entry point and stub subcommand files** + +`lib/apartment/cli.rb`: +```ruby +# frozen_string_literal: true + +require 'thor' +require_relative 'cli/tenants' +require_relative 'cli/migrations' +require_relative 'cli/seeds' +require_relative 'cli/pool' + +module Apartment + class CLI < Thor + def self.exit_on_failure? = true + + register CLI::Tenants, 'tenants', 'tenants COMMAND', 'Tenant lifecycle commands' + register CLI::Migrations, 'migrations', 'migrations COMMAND', 'Migration commands' + register CLI::Seeds, 'seeds', 'seeds COMMAND', 'Seed commands' + register CLI::Pool, 'pool', 'pool COMMAND', 'Connection pool commands' + end +end +``` + +`lib/apartment/cli/tenants.rb`: +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Tenants < Thor + def self.exit_on_failure? = true + end + end +end +``` + +`lib/apartment/cli/migrations.rb`: +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Migrations < Thor + def self.exit_on_failure? = true + end + end +end +``` + +`lib/apartment/cli/seeds.rb`: +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Seeds < Thor + def self.exit_on_failure? = true + end + end +end +``` + +`lib/apartment/cli/pool.rb`: +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Pool < Thor + def self.exit_on_failure? = true + end + end +end +``` + +- [ ] **Step 18: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/cli_spec.rb --format documentation` +Expected: All 5 examples PASS + +- [ ] **Step 19: Commit** + +```bash +git add lib/apartment/cli.rb lib/apartment/cli/ spec/unit/cli_spec.rb +git commit -m "Add Apartment::CLI entry point with subcommand registration + +Registers Tenants, Migrations, Seeds, and Pool as Thor subcommands. +Subcommand classes are stubs; commands added in subsequent commits." +``` + +--- + +### Task 8: CLI Tenants — tests + +**Files:** +- Create: `spec/unit/cli/tenants_spec.rb` + +- [ ] **Step 20: Write the tenants CLI tests** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Tenants) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'create' do + before do + allow(Apartment::Tenant).to(receive(:create)) + end + + it 'creates a single tenant when given an argument' do + run_command('create', 'acme') + expect(Apartment::Tenant).to(have_received(:create).with('acme')) + end + + it 'creates all tenants when no argument given' do + run_command('create') + expect(Apartment::Tenant).to(have_received(:create).with('acme')) + expect(Apartment::Tenant).to(have_received(:create).with('beta')) + end + + it 'skips tenants that already exist' do + allow(Apartment::Tenant).to(receive(:create).with('acme') + .and_raise(Apartment::TenantExists.new('acme'))) + output = run_command('create') + expect(output).to(include('already exists')) + end + + it 'collects errors and reports failures' do + allow(Apartment::Tenant).to(receive(:create).with('acme') + .and_raise(StandardError, 'connection refused')) + allow(Apartment::Tenant).to(receive(:create).with('beta')) + expect { run_command('create') }.to(raise_error(SystemExit)) + end + + it 'suppresses per-tenant output with --quiet' do + output = run_command('create', '--quiet') + expect(output).not_to(include('Creating')) + end + end + + describe 'drop' do + before do + allow(Apartment::Tenant).to(receive(:drop)) + end + + it 'drops the specified tenant with --force' do + run_command('drop', 'acme', '--force') + expect(Apartment::Tenant).to(have_received(:drop).with('acme')) + end + + it 'prompts for confirmation without --force' do + instance = described_class.new + allow(instance).to(receive(:yes?).and_return(false)) + allow(instance).to(receive(:say)) + instance.invoke(:drop, ['acme']) + expect(Apartment::Tenant).not_to(have_received(:drop)) + end + + it 'proceeds when confirmation is accepted' do + instance = described_class.new + allow(instance).to(receive(:yes?).and_return(true)) + allow(instance).to(receive(:say)) + instance.invoke(:drop, ['acme']) + expect(Apartment::Tenant).to(have_received(:drop).with('acme')) + end + end + + describe 'list' do + it 'prints all tenant names' do + output = run_command('list') + expect(output).to(include('acme')) + expect(output).to(include('beta')) + end + end + + describe 'current' do + it 'prints the current tenant' do + Apartment::Current.tenant = 'acme' + output = run_command('current') + expect(output.strip).to(eq('acme')) + end + + it 'prints default_tenant when no current tenant' do + output = run_command('current') + expect(output.strip).to(eq('public')) + end + + it 'prints none when no tenant context' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + end + output = run_command('current') + expect(output.strip).to(eq('none')) + end + end + + describe 'APARTMENT_FORCE env var' do + before do + allow(Apartment::Tenant).to(receive(:drop)) + end + + it 'skips confirmation when APARTMENT_FORCE=1' do + original = ENV.fetch('APARTMENT_FORCE', nil) + ENV['APARTMENT_FORCE'] = '1' + run_command('drop', 'acme') + expect(Apartment::Tenant).to(have_received(:drop).with('acme')) + ensure + ENV['APARTMENT_FORCE'] = original + end + end + + describe 'APARTMENT_QUIET env var' do + before do + allow(Apartment::Tenant).to(receive(:create)) + end + + it 'suppresses output when APARTMENT_QUIET=1' do + original = ENV.fetch('APARTMENT_QUIET', nil) + ENV['APARTMENT_QUIET'] = '1' + output = run_command('create') + expect(output).not_to(include('Creating')) + ensure + ENV['APARTMENT_QUIET'] = original + end + end +end +``` + +- [ ] **Step 21: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/cli/tenants_spec.rb --format documentation` +Expected: FAIL — `create`, `drop`, `list`, `current` methods not defined + +--- + +### Task 9: CLI Tenants — implementation + +**Files:** +- Modify: `lib/apartment/cli/tenants.rb` + +- [ ] **Step 22: Implement the Tenants CLI** + +Replace `lib/apartment/cli/tenants.rb` with: + +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Tenants < Thor + def self.exit_on_failure? = true + + desc 'create [TENANT]', 'Create tenant schema/database' + long_desc <<~DESC + Without arguments, creates all tenants returned by tenants_provider. + With a TENANT argument, creates only that tenant. + Skips tenants that already exist (no error). + DESC + method_option :quiet, type: :boolean, desc: 'Suppress per-tenant output' + def create(tenant = nil) + if tenant + create_single(tenant) + else + create_all + end + end + + desc 'drop TENANT', 'Drop a tenant schema/database' + long_desc <<~DESC + Drops the specified tenant. Requires confirmation unless --force is set. + There is no "drop all" — this is intentionally a single-tenant operation. + DESC + method_option :force, type: :boolean, desc: 'Skip confirmation prompt' + def drop(tenant) + unless force? + return say('Cancelled.') unless yes?("Drop tenant '#{tenant}'? This cannot be undone. [y/N]") + end + + Apartment::Tenant.drop(tenant) + say("Dropped tenant: #{tenant}") unless quiet? + end + + desc 'list', 'List all tenants' + def list + Apartment.config.tenants_provider.call.each { |t| say(t) } + end + + desc 'current', 'Show current tenant' + def current + say(Apartment::Current.tenant || Apartment.config&.default_tenant || 'none') + end + + private + + def create_single(tenant) + say("Creating tenant: #{tenant}") unless quiet? + Apartment::Tenant.create(tenant) + say(" created") unless quiet? + rescue Apartment::TenantExists + say(" already exists, skipping") unless quiet? + end + + def create_all + tenants = Apartment.config.tenants_provider.call + failed = [] + tenants.each do |t| + say("Creating tenant: #{t}") unless quiet? + Apartment::Tenant.create(t) + say(" created") unless quiet? + rescue Apartment::TenantExists + say(" already exists, skipping") unless quiet? + rescue StandardError => e + warn(" FAILED: #{e.message}") + failed << t + end + return if failed.empty? + + raise(Thor::Error, "apartment tenants create failed for #{failed.size} tenant(s): #{failed.join(', ')}") + end + + def force? + options[:force] || ENV['APARTMENT_FORCE'] == '1' + end + + def quiet? + options[:quiet] || ENV['APARTMENT_QUIET'] == '1' + end + end + end +end +``` + +- [ ] **Step 23: Run tests** + +Run: `bundle exec rspec spec/unit/cli/tenants_spec.rb --format documentation` +Expected: All examples PASS (some env var tests may need adjustment based on climate_control availability) + +- [ ] **Step 24: Commit** + +```bash +git add lib/apartment/cli/tenants.rb spec/unit/cli/tenants_spec.rb +git commit -m "Add CLI tenants subcommand: create, drop, list, current + +Supports single-tenant and all-tenants create, confirmation-gated drop, +list from tenants_provider, and current tenant display. Env var +overrides: APARTMENT_FORCE, APARTMENT_QUIET." +``` + +--- + +### Task 10: CLI Migrations — tests + +**Files:** +- Create: `spec/unit/cli/migrations_spec.rb` + +- [ ] **Step 25: Write the migrations CLI tests** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Migrations) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'migrate' do + let(:migration_run) do + Apartment::Migrator::MigrationRun.new( + results: [ + Apartment::Migrator::Result.new( + tenant: 'public', status: :success, duration: 0.1, error: nil, versions_run: [] + ), + Apartment::Migrator::Result.new( + tenant: 'acme', status: :success, duration: 0.2, error: nil, versions_run: [] + ), + ], + total_duration: 0.3, + threads: 0 + ) + end + + context 'without tenant argument (all tenants)' do + before do + allow(Apartment::Migrator).to(receive(:new).and_return(double(run: migration_run))) + allow(ActiveRecord).to(receive(:dump_schema_after_migration).and_return(false)) + end + + it 'delegates to Migrator#run' do + run_command('migrate') + expect(Apartment::Migrator).to(have_received(:new)) + end + + it 'prints the migration summary' do + output = run_command('migrate') + expect(output).to(include('tenants')) + end + + it 'passes --threads to Migrator' do + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(threads: 4)).and_return(double(run: migration_run))) + run_command('migrate', '--threads=4') + end + + it 'passes --version to Migrator' do + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(version: 20_260_401)).and_return(double(run: migration_run))) + run_command('migrate', '--version=20260401') + end + + it 'falls back to ENV VERSION when --version not given' do + original = ENV.fetch('VERSION', nil) + ENV['VERSION'] = '20260401' + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(version: 20_260_401)).and_return(double(run: migration_run))) + run_command('migrate') + ensure + ENV['VERSION'] = original + end + + it 'defaults threads to config value' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme] } + c.default_tenant = 'public' + c.parallel_migration_threads = 8 + end + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(threads: 8)).and_return(double(run: migration_run))) + run_command('migrate') + end + + it 'exits non-zero when migration fails' do + failed_run = Apartment::Migrator::MigrationRun.new( + results: [ + Apartment::Migrator::Result.new( + tenant: 'acme', status: :failed, duration: 0.1, + error: StandardError.new('boom'), versions_run: [] + ), + ], + total_duration: 0.1, + threads: 0 + ) + allow(Apartment::Migrator).to(receive(:new).and_return(double(run: failed_run))) + expect { run_command('migrate') }.to(raise_error(SystemExit)) + end + end + + context 'with tenant argument (single tenant)' do + let(:result) do + Apartment::Migrator::Result.new( + tenant: 'acme', status: :success, duration: 0.2, error: nil, versions_run: [1] + ) + end + + before do + allow(Apartment::Migrator).to(receive(:new).and_return(double(migrate_one: result))) + end + + it 'delegates to Migrator#migrate_one' do + migrator = double + allow(Apartment::Migrator).to(receive(:new).and_return(migrator)) + expect(migrator).to(receive(:migrate_one).with('acme').and_return(result)) + run_command('migrate', 'acme') + end + + it 'prints success message' do + output = run_command('migrate', 'acme') + expect(output).to(include('acme')) + end + + it 'exits non-zero on failure' do + failed = Apartment::Migrator::Result.new( + tenant: 'acme', status: :failed, duration: 0.1, + error: StandardError.new('boom'), versions_run: [] + ) + allow(Apartment::Migrator).to(receive(:new).and_return(double(migrate_one: failed))) + expect { run_command('migrate', 'acme') }.to(raise_error(SystemExit)) + end + end + end + + describe 'rollback' do + let(:mock_migration_context) { double('MigrationContext') } + let(:mock_pool) { double('pool', migration_context: mock_migration_context) } + + before do + allow(mock_migration_context).to(receive(:rollback)) + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(mock_pool)) + allow(Apartment::Tenant).to(receive(:switch)) { |_t, &block| block.call } + end + + it 'rolls back all tenants by default' do + run_command('rollback') + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + expect(Apartment::Tenant).to(have_received(:switch).with('beta')) + expect(mock_migration_context).to(have_received(:rollback).with(1).twice) + end + + it 'rolls back a single tenant when given' do + run_command('rollback', 'acme') + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + expect(Apartment::Tenant).not_to(have_received(:switch).with('beta')) + end + + it 'respects --step option' do + run_command('rollback', '--step=3') + expect(mock_migration_context).to(have_received(:rollback).with(3).twice) + end + + it 'exits non-zero when a tenant fails' do + allow(Apartment::Tenant).to(receive(:switch).with('acme') + .and_raise(StandardError, 'boom')) + allow(Apartment::Tenant).to(receive(:switch).with('beta')) { |_t, &block| block.call } + expect { run_command('rollback') }.to(raise_error(SystemExit)) + end + end +end +``` + +- [ ] **Step 26: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/cli/migrations_spec.rb --format documentation` +Expected: FAIL — `migrate`, `rollback` methods not defined + +--- + +### Task 11: CLI Migrations — implementation + +**Files:** +- Modify: `lib/apartment/cli/migrations.rb` + +- [ ] **Step 27: Implement the Migrations CLI** + +Replace `lib/apartment/cli/migrations.rb` with: + +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Migrations < Thor + def self.exit_on_failure? = true + + desc 'migrate [TENANT]', 'Run migrations for tenants' + long_desc <<~DESC + Without arguments, migrates all tenants (primary DB first, then tenants + from tenants_provider). With a TENANT argument, migrates only that tenant. + + Uses Apartment::Migrator for both paths, preserving RBAC role wrapping, + advisory lock management, and instrumentation. + DESC + # Thor :numeric handles large integers (e.g. 20260401000000 timestamps) correctly. + method_option :version, type: :numeric, desc: 'Target migration version (also reads ENV VERSION)' + method_option :threads, type: :numeric, desc: 'Override parallel_migration_threads from config' + def migrate(tenant = nil) + require 'apartment/migrator' + + if tenant + migrate_single(tenant) + else + migrate_all + end + end + + desc 'rollback [TENANT]', 'Rollback migrations for tenants' + long_desc <<~DESC + Without arguments, rolls back all tenants sequentially. + With a TENANT argument, rolls back only that tenant. + DESC + method_option :step, type: :numeric, default: 1, desc: 'Number of steps to rollback' + def rollback(tenant = nil) + if tenant + rollback_single(tenant) + else + rollback_all + end + end + + private + + def migrate_single(tenant) + migrator = Apartment::Migrator.new(version: resolve_version) + result = migrator.migrate_one(tenant) + if result.status == :failed + raise(Thor::Error, "Migration failed for #{tenant}: #{result.error&.class}: #{result.error&.message}") + end + + say("Migrated tenant: #{tenant} (#{result.status}, #{result.duration.round(2)}s)") + end + + def migrate_all + threads = options[:threads] || Apartment.config.parallel_migration_threads + migrator = Apartment::Migrator.new(threads: threads, version: resolve_version) + result = migrator.run + say(result.summary) + + trigger_schema_dump if result.success? + raise(Thor::Error, "Migration failed for #{result.failed.size} tenant(s)") unless result.success? + end + + def rollback_single(tenant) + step = options[:step] + say("Rolling back tenant: #{tenant} (#{step} step(s))") + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection_pool.migration_context.rollback(step) + end + say(" done") + end + + def rollback_all + step = options[:step] + tenants = Apartment.config.tenants_provider.call + failed = [] + tenants.each do |t| + say("Rolling back tenant: #{t} (#{step} step(s))") + Apartment::Tenant.switch(t) do + ActiveRecord::Base.connection_pool.migration_context.rollback(step) + end + say(" done") + rescue StandardError => e + warn(" FAILED: #{e.message}") + failed << t + end + return if failed.empty? + + raise(Thor::Error, "Rollback failed for #{failed.size} tenant(s): #{failed.join(', ')}") + end + + def resolve_version + v = options[:version] || ENV['VERSION']&.to_i + v&.zero? ? nil : v + end + + def trigger_schema_dump + return unless defined?(ActiveRecord) && ActiveRecord.dump_schema_after_migration + return unless defined?(Rake::Task) && Rake::Task.task_defined?('db:schema:dump') + + Rake::Task['db:schema:dump'].invoke + end + end + end +end +``` + +- [ ] **Step 28: Run tests** + +Run: `bundle exec rspec spec/unit/cli/migrations_spec.rb --format documentation` +Expected: All examples PASS + +- [ ] **Step 29: Commit** + +```bash +git add lib/apartment/cli/migrations.rb spec/unit/cli/migrations_spec.rb +git commit -m "Add CLI migrations subcommand: migrate, rollback + +migrate delegates to Migrator#run (all) or Migrator#migrate_one (single). +Supports --version, --threads, and ENV VERSION fallback. rollback iterates +tenants sequentially with --step option." +``` + +--- + +### Task 12: CLI Seeds — tests + +**Files:** +- Create: `spec/unit/cli/seeds_spec.rb` + +- [ ] **Step 30: Write the seeds CLI tests** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Seeds) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + allow(Apartment::Tenant).to(receive(:seed)) + end + + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'seed' do + it 'seeds a single tenant when given an argument' do + run_command('seed', 'acme') + expect(Apartment::Tenant).to(have_received(:seed).with('acme')) + end + + it 'seeds all tenants when no argument given' do + run_command('seed') + expect(Apartment::Tenant).to(have_received(:seed).with('acme')) + expect(Apartment::Tenant).to(have_received(:seed).with('beta')) + end + + it 'collects errors and exits non-zero' do + allow(Apartment::Tenant).to(receive(:seed).with('acme') + .and_raise(StandardError, 'seed error')) + expect { run_command('seed') }.to(raise_error(SystemExit)) + end + + it 'prints per-tenant output' do + output = run_command('seed') + expect(output).to(include('acme')) + expect(output).to(include('beta')) + end + end +end +``` + +- [ ] **Step 31: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/cli/seeds_spec.rb --format documentation` +Expected: FAIL — `seed` method not defined + +--- + +### Task 13: CLI Seeds — implementation + +**Files:** +- Modify: `lib/apartment/cli/seeds.rb` + +- [ ] **Step 32: Implement the Seeds CLI** + +Replace `lib/apartment/cli/seeds.rb` with: + +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Seeds < Thor + def self.exit_on_failure? = true + + desc 'seed [TENANT]', 'Seed tenant databases' + long_desc <<~DESC + Without arguments, seeds all tenants from tenants_provider. + With a TENANT argument, seeds only that tenant. + DESC + def seed(tenant = nil) + if tenant + seed_single(tenant) + else + seed_all + end + end + + private + + def seed_single(tenant) + say("Seeding tenant: #{tenant}") + Apartment::Tenant.seed(tenant) + say(" done") + end + + def seed_all + tenants = Apartment.config.tenants_provider.call + failed = [] + tenants.each do |t| + say("Seeding tenant: #{t}") + Apartment::Tenant.seed(t) + say(" done") + rescue StandardError => e + warn(" FAILED: #{e.message}") + failed << t + end + return if failed.empty? + + raise(Thor::Error, "Seed failed for #{failed.size} tenant(s): #{failed.join(', ')}") + end + end + end +end +``` + +- [ ] **Step 33: Run tests** + +Run: `bundle exec rspec spec/unit/cli/seeds_spec.rb --format documentation` +Expected: All 4 examples PASS + +- [ ] **Step 34: Commit** + +```bash +git add lib/apartment/cli/seeds.rb spec/unit/cli/seeds_spec.rb +git commit -m "Add CLI seeds subcommand + +Supports single-tenant and all-tenants seeding via Apartment::Tenant.seed. +Collects errors across tenants and exits non-zero on failure." +``` + +--- + +### Task 14: CLI Pool — tests + +**Files:** +- Create: `spec/unit/cli/pool_spec.rb` + +- [ ] **Step 35: Write the pool CLI tests** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Pool) do + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'stats' do + context 'when pool_manager is configured' do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + it 'prints pool summary' do + Apartment.pool_manager.fetch_or_create('acme') { double('pool') } + output = run_command('stats') + expect(output).to(include('pool')) + end + + it 'prints per-tenant details with --verbose' do + Apartment.pool_manager.fetch_or_create('acme') { double('pool') } + output = run_command('stats', '--verbose') + expect(output).to(include('acme')) + end + end + + context 'when pool_manager is nil' do + before { Apartment.clear_config } + + it 'prints a not-configured message' do + output = run_command('stats') + expect(output).to(include('not configured')) + end + end + end + + describe 'evict' do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + end + + it 'runs eviction cycle with --force' do + allow(Apartment.pool_reaper).to(receive(:run_cycle).and_return(3)) + output = run_command('evict', '--force') + expect(output).to(include('3')) + expect(Apartment.pool_reaper).to(have_received(:run_cycle)) + end + + it 'prompts without --force' do + instance = described_class.new + allow(instance).to(receive(:yes?).and_return(false)) + allow(instance).to(receive(:say)) + instance.invoke(:evict) + expect(Apartment.pool_reaper).not_to(have_received(:run_cycle)) if Apartment.pool_reaper + end + + it 'reports when pool_reaper is nil' do + Apartment.clear_config + output = run_command('evict', '--force') + expect(output).to(include('not configured')) + end + end +end +``` + +- [ ] **Step 36: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/cli/pool_spec.rb --format documentation` +Expected: FAIL — `stats`, `evict` methods not defined + +--- + +### Task 15: CLI Pool — implementation + +**Files:** +- Modify: `lib/apartment/cli/pool.rb` + +- [ ] **Step 37: Implement the Pool CLI** + +Replace `lib/apartment/cli/pool.rb` with: + +```ruby +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Pool < Thor + def self.exit_on_failure? = true + + desc 'stats', 'Show connection pool statistics' + long_desc <<~DESC + Displays pool summary: total pools and tenant list. + With --verbose, shows per-tenant idle time. + DESC + method_option :verbose, type: :boolean, desc: 'Per-tenant breakdown' + def stats + unless Apartment.pool_manager + say('Apartment is not configured. Run Apartment.configure first.') + return + end + + pool_stats = Apartment.pool_manager.stats + say("Total pools: #{pool_stats[:total_pools]}") + + if options[:verbose] && pool_stats[:tenants]&.any? + say("\nPer-tenant details:") + pool_stats[:tenants].each do |tenant_key| + tenant_stats = Apartment.pool_manager.stats_for(tenant_key) + idle = tenant_stats ? "#{tenant_stats[:seconds_idle].round(1)}s idle" : 'unknown' + say(" #{tenant_key}: #{idle}") + end + elsif pool_stats[:tenants]&.any? + say("Tenants: #{pool_stats[:tenants].join(', ')}") + end + end + + desc 'evict', 'Force idle pool eviction' + long_desc <<~DESC + Triggers one synchronous eviction cycle (idle + LRU). + Requires confirmation unless --force is set. + DESC + method_option :force, type: :boolean, desc: 'Skip confirmation prompt' + def evict + unless Apartment.pool_reaper + say('Apartment is not configured. Run Apartment.configure first.') + return + end + + unless force? + return say('Cancelled.') unless yes?('Run pool eviction cycle? [y/N]') + end + + count = Apartment.pool_reaper.run_cycle + say("Evicted #{count} pool(s).") + end + + private + + def force? + options[:force] || ENV['APARTMENT_FORCE'] == '1' + end + end + end +end +``` + +- [ ] **Step 38: Run tests** + +Run: `bundle exec rspec spec/unit/cli/pool_spec.rb --format documentation` +Expected: All examples PASS + +- [ ] **Step 39: Commit** + +```bash +git add lib/apartment/cli/pool.rb spec/unit/cli/pool_spec.rb +git commit -m "Add CLI pool subcommand: stats, evict + +stats shows pool summary with optional --verbose per-tenant breakdown. +evict triggers PoolReaper#run_cycle with confirmation gate. +Both guard against nil pool_manager/pool_reaper." +``` + +--- + +### Task 16: Rake refactor + +**Files:** +- Modify: `lib/apartment/tasks/v4.rake` + +- [ ] **Step 40: Replace v4.rake with thin CLI wrappers** + +Replace the entire contents of `lib/apartment/tasks/v4.rake` with: + +```ruby +# frozen_string_literal: true + +require 'apartment/cli' + +namespace :apartment do + desc 'Create all tenant schemas/databases (or one: rake apartment:create[tenant])' + task :create, [:tenant] => :environment do |_t, args| + if args[:tenant] + Apartment::CLI::Tenants.new.invoke(:create, [args[:tenant]]) + else + Apartment::CLI::Tenants.new.invoke(:create) + end + end + + desc 'Drop a tenant schema/database' + task :drop, [:tenant] => :environment do |_t, args| + abort('Usage: rake apartment:drop[tenant_name]') unless args[:tenant] + Apartment::CLI::Tenants.new.invoke(:drop, [args[:tenant]], force: true) + end + + desc 'Run migrations for all tenants (or one: rake apartment:migrate[tenant])' + task :migrate, [:tenant] => :environment do |_t, args| + if args[:tenant] + Apartment::CLI::Migrations.new.invoke(:migrate, [args[:tenant]]) + else + Apartment::CLI::Migrations.new.invoke(:migrate) + end + end + + desc 'Seed all tenants' + task seed: :environment do + Apartment::CLI::Seeds.new.invoke(:seed) + end + + desc 'Rollback migrations for all tenants' + task :rollback, [:step] => :environment do |_t, args| + Apartment::CLI::Migrations.new.invoke(:rollback, [], step: (args[:step] || 1).to_i) + end + + namespace :schema do + namespace :cache do + desc 'Dump schema cache for each tenant' + task dump: :environment do + require 'apartment/schema_cache' + paths = Apartment::SchemaCache.dump_all + paths.each { |p| puts("Dumped: #{p}") } + end + end + end +end +``` + +**Note:** `drop` via rake passes `force: true` because rake tasks are non-interactive. This is a behavior change from prior v4.rake (which called `Tenant.drop` directly); `rake apartment:drop[tenant]` now routes through Thor with forced drop. Equivalent to `apartment tenants drop TENANT --force`. + +- [ ] **Step 41: Run the full unit test suite to verify no regressions** + +Run: `bundle exec rspec spec/unit/ --format documentation` +Expected: All tests pass + +- [ ] **Step 42: Commit** + +```bash +git add lib/apartment/tasks/v4.rake +git commit -m "Refactor v4.rake to thin wrappers delegating to CLI + +Logic now lives in Thor CLI classes. Rake tasks are one-liners that +invoke the corresponding CLI method. Rake drop passes force: true +(non-interactive). Schema cache dump stays as-is." +``` + +--- + +### Task 17: Full test run and lint + +- [ ] **Step 43: Run full unit test suite** + +Run: `bundle exec rspec spec/unit/ --format documentation` +Expected: All tests pass + +- [ ] **Step 44: Run rubocop** + +Run: `bundle exec rubocop lib/apartment/cli.rb lib/apartment/cli/ lib/apartment/migrator.rb lib/apartment/pool_reaper.rb lib/apartment/tasks/v4.rake lib/apartment.rb` +Expected: No offenses. If there are offenses, fix them. + +- [ ] **Step 45: Run tests across Rails versions** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/` +Expected: All tests pass + +- [ ] **Step 46: Commit any lint fixes** + +Only if Step 44 produced fixes: +```bash +git add -A +git commit -m "Fix rubocop offenses in CLI files" +``` + +--- + +## Phase 6.2: Generator + Binstub + +### Task 18: Generator spec + +**Files:** +- Create: `spec/unit/generator/install_generator_spec.rb` + +- [ ] **Step 47: Write the generator test** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rails/generators' +require 'rails/generators/testing/behavior' +require 'rails/generators/testing/assertions' +require_relative '../../../lib/generators/apartment/install/install_generator' + +RSpec.describe(Apartment::InstallGenerator) do + include FileUtils + + let(:destination) { Dir.mktmpdir } + + before do + described_class.start([], destination_root: destination, quiet: true) + end + + after do + rm_rf(destination) + end + + describe 'initializer' do + let(:initializer_path) { File.join(destination, 'config', 'initializers', 'apartment.rb') } + + it 'creates the initializer file' do + expect(File.exist?(initializer_path)).to(be(true)) + end + + it 'contains tenant_strategy' do + content = File.read(initializer_path) + expect(content).to(include('config.tenant_strategy')) + end + + it 'contains tenants_provider' do + content = File.read(initializer_path) + expect(content).to(include('config.tenants_provider')) + end + + it 'does not contain v3 references' do + content = File.read(initializer_path) + expect(content).not_to(include('tenant_names')) + expect(content).not_to(include('use_schemas')) + expect(content).not_to(include('use_sql')) + expect(content).not_to(include('prepend_environment')) + expect(content).not_to(include('pg_excluded_names')) + expect(content).not_to(include('middleware.use')) + end + + it 'does not require elevator files' do + content = File.read(initializer_path) + expect(content).not_to(include("require 'apartment/elevators")) + end + + it 'includes RBAC options in comments' do + content = File.read(initializer_path) + expect(content).to(include('migration_role')) + expect(content).to(include('app_role')) + end + + it 'includes elevator options in comments' do + content = File.read(initializer_path) + expect(content).to(include('config.elevator')) + expect(content).to(include('elevator_options')) + end + end + + describe 'binstub' do + let(:binstub_path) { File.join(destination, 'bin', 'apartment') } + + it 'creates the binstub file' do + expect(File.exist?(binstub_path)).to(be(true)) + end + + it 'is executable' do + expect(File.executable?(binstub_path)).to(be(true)) + end + + it 'requires config/environment' do + content = File.read(binstub_path) + expect(content).to(include("require_relative '../config/environment'")) + end + + it 'requires apartment/cli' do + content = File.read(binstub_path) + expect(content).to(include("require 'apartment/cli'")) + end + + it 'starts CLI' do + content = File.read(binstub_path) + expect(content).to(include('Apartment::CLI.start')) + end + end +end +``` + +- [ ] **Step 48: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/generator/install_generator_spec.rb --format documentation` +Expected: FAIL — binstub template not found / binstub not created + +--- + +### Task 19: Generator implementation + +**Files:** +- Modify: `lib/generators/apartment/install/install_generator.rb` +- Rewrite: `lib/generators/apartment/install/templates/apartment.rb` +- Create: `lib/generators/apartment/install/templates/binstub` + +- [ ] **Step 49: Update the install generator** + +Replace `lib/generators/apartment/install/install_generator.rb` with: + +```ruby +# frozen_string_literal: true + +module Apartment + class InstallGenerator < Rails::Generators::Base + source_root File.expand_path('templates', __dir__) + + def copy_initializer + template('apartment.rb', File.join('config', 'initializers', 'apartment.rb')) + end + + def copy_binstub + template('binstub', File.join('bin', 'apartment')) + chmod(File.join('bin', 'apartment'), 0o755) + end + end +end +``` + +- [ ] **Step 50: Rewrite the initializer template** + +Replace `lib/generators/apartment/install/templates/apartment.rb` with: + +```ruby +# frozen_string_literal: true + +Apartment.configure do |config| + # == Required =========================================================== + + # Tenant isolation strategy. + # :schema - PostgreSQL schemas (one schema per tenant, single DB) + # :database_name - Separate database per tenant (MySQL, PostgreSQL) + config.tenant_strategy = :schema + + # Returns an array of tenant identifiers. Called at runtime by migrate, + # create, seed, and other bulk operations. + config.tenants_provider = -> { raise "TODO: replace with e.g. Account.pluck(:subdomain)" } + + # == Tenant Defaults ===================================================== + + # The default tenant (used on boot and between requests). + # config.default_tenant = 'public' + + # Models that live in the shared/default schema (not per-tenant). + # config.excluded_models = %w[Account] + + # == Connection Pool ===================================================== + + # config.tenant_pool_size = 5 + # config.pool_idle_timeout = 300 + # config.max_total_connections = nil + + # == Elevator (Request Tenant Detection) ================================= + + # The Railtie auto-inserts the elevator as middleware. No manual + # middleware.use call needed. + # + # config.elevator = :subdomain + # config.elevator_options = {} + + # == Migrations ========================================================== + + # config.parallel_migration_threads = 0 + # config.schema_load_strategy = nil # :schema_rb or :sql + # config.seed_after_create = false + # config.check_pending_migrations = true + + # == RBAC & Roles ========================================================= + + # config.migration_role = nil # e.g. :db_manager (Phase 5 role-aware connections) + # config.app_role = nil # e.g. 'app_role' or -> { "app_#{Rails.env}" } + # config.environmentify_strategy = nil # nil, :prepend, :append, or a callable + + # == PostgreSQL =========================================================== + + # config.configure_postgres do |pg| + # pg.persistent_schemas = %w[shared extensions] + # end + + # == MySQL ================================================================ + + # config.configure_mysql do |my| + # end +end +``` + +- [ ] **Step 51: Create the binstub template** + +Create `lib/generators/apartment/install/templates/binstub`: + +```ruby +#!/usr/bin/env ruby +require_relative '../config/environment' +require 'apartment/cli' +Apartment::CLI.start(ARGV) +``` + +- [ ] **Step 52: Run tests** + +Run: `bundle exec rspec spec/unit/generator/install_generator_spec.rb --format documentation` +Expected: All examples PASS + +- [ ] **Step 53: Commit** + +```bash +git add lib/generators/apartment/install/ spec/unit/generator/ +git commit -m "Rewrite install generator for v4: initializer + binstub + +Initializer template uses v4 Config API with minimal scaffold (only +tenant_strategy and tenants_provider uncommented). Adds bin/apartment +binstub generation. No v3 references (tenant_names, use_schemas, +manual middleware insertion)." +``` + +--- + +### Task 20: Final validation + +- [ ] **Step 54: Run full unit test suite** + +Run: `bundle exec rspec spec/unit/ --format documentation` +Expected: All tests pass + +- [ ] **Step 55: Run rubocop on all changed files** + +Run: `bundle exec rubocop lib/generators/ spec/unit/generator/` +Expected: No offenses + +- [ ] **Step 56: Run across Rails versions** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/` +Expected: All tests pass + +- [ ] **Step 57: Commit any fixes** + +Only if needed: +```bash +git add -A +git commit -m "Fix lint offenses in generator files" +``` diff --git a/docs/plans/apartment-v4/phase-7-integration-tests.md b/docs/plans/apartment-v4/phase-7-integration-tests.md new file mode 100644 index 00000000..f4d030e0 --- /dev/null +++ b/docs/plans/apartment-v4/phase-7-integration-tests.md @@ -0,0 +1,712 @@ +# Phase 7: Integration & Stress Tests — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fill remaining integration test gaps: fiber safety, memory stability, CLI integration, and thread safety hardening. + +**Architecture:** Three new spec files in `spec/integration/v4/` (flat structure, no subdirectories) plus two additive `it` blocks in the existing `stress_spec.rb`. One drive-by fix in `coverage_gaps_spec.rb`. All specs use the existing `V4IntegrationHelper` setup pattern. + +**Tech Stack:** RSpec, concurrent-ruby (`CyclicBarrier`, `Map`, `Array`), ActiveRecord, Thor CLI classes. + +--- + +### Task 1: Create `fiber_safety_spec.rb` + +**Files:** +- Create: `spec/integration/v4/fiber_safety_spec.rb` + +- [ ] **Step 1: Write the spec file with all fiber safety tests** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Fiber safety integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_fiber') } + let(:tenants) { %w[fiber_a fiber_b] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'isolates tenant state across fibers' do + Apartment::Tenant.switch('fiber_a') do + child_tenant = Fiber.new do + Apartment::Tenant.switch('fiber_b') do + Fiber.yield Apartment::Tenant.current + end + end + + child_result = child_tenant.resume + expect(child_result).to(eq('fiber_b')) + expect(Apartment::Tenant.current).to(eq('fiber_a')) + expect(Apartment::Current.tenant).to(eq('fiber_a')) + end + end + + it 'preserves tenant across Fiber.yield/resume cycles' do + fiber = Fiber.new do + Apartment::Tenant.switch('fiber_a') do + Fiber.yield :switched + Apartment::Tenant.current + end + end + + expect(fiber.resume).to(eq(:switched)) + expect(fiber.resume).to(eq('fiber_a')) + end + + it 'outer switch block unaffected by inner fiber switching' do + Apartment::Tenant.switch('fiber_a') do + Widget.create!(name: 'outer') + + fiber = Fiber.new do + Apartment::Tenant.switch('fiber_b') do + Widget.create!(name: 'inner') + Apartment::Tenant.current + end + end + + inner_tenant = fiber.resume + expect(inner_tenant).to(eq('fiber_b')) + expect(Apartment::Tenant.current).to(eq('fiber_a')) + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('outer')) + end + + Apartment::Tenant.switch('fiber_b') do + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('inner')) + end + end + + context 'Fiber.scheduler integration', + skip: (RUBY_VERSION < '3.1' ? 'requires Ruby 3.1+ for Fiber.scheduler' : false) do + it 'tenant state does not leak across scheduled fibers' do + results = [] + mutex = Mutex.new + + scheduler = Fiber::Scheduler.new if defined?(Fiber::Scheduler) + skip 'no built-in Fiber::Scheduler available' unless scheduler + + Fiber.set_scheduler(scheduler) + + Fiber.schedule do + Apartment::Tenant.switch('fiber_a') do + sleep(0.01) # yield to scheduler + mutex.synchronize { results << { fiber: :a, tenant: Apartment::Tenant.current } } + end + end + + Fiber.schedule do + Apartment::Tenant.switch('fiber_b') do + sleep(0.01) # yield to scheduler + mutex.synchronize { results << { fiber: :b, tenant: Apartment::Tenant.current } } + end + end + + Fiber.scheduler.close + Fiber.set_scheduler(nil) + + a_result = results.find { |r| r[:fiber] == :a } + b_result = results.find { |r| r[:fiber] == :b } + + expect(a_result).not_to(be_nil, 'Fiber A did not produce a result') + expect(b_result).not_to(be_nil, 'Fiber B did not produce a result') + expect(a_result[:tenant]).to(eq('fiber_a')) + expect(b_result[:tenant]).to(eq('fiber_b')) + end + end + + context 'load_async integration', + skip: (ActiveRecord::Relation.method_defined?(:load_async) ? false : 'requires load_async support') do + it 'async relation resolves against the correct tenant pool' do + Apartment::Tenant.switch('fiber_a') do + Widget.create!(name: 'async_test') + end + + Apartment::Tenant.switch('fiber_a') do + relation = Widget.where(name: 'async_test').load_async + # Force resolution + results = relation.to_a + expect(results.size).to(eq(1)) + expect(results.first.name).to(eq('async_test')) + end + + # Verify it didn't leak into fiber_b + Apartment::Tenant.switch('fiber_b') do + expect(Widget.where(name: 'async_test').count).to(eq(0)) + end + end + end +end +``` + +- [ ] **Step 2: Run the spec on SQLite to verify it passes** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/fiber_safety_spec.rb --format documentation` + +Expected: All non-skipped examples pass. The Fiber.scheduler test will likely skip (no built-in scheduler in MRI). The load_async test should pass if the Rails version supports it. + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/fiber_safety_spec.rb +git commit -m "Add fiber safety integration spec + +Proves CurrentAttributes isolates tenant state across fibers: +basic isolation, yield/resume cycles, nested switch blocks, +conditional Fiber.scheduler and load_async tests." +``` + +--- + +### Task 2: Create `memory_stability_spec.rb` + +**Files:** +- Create: `spec/integration/v4/memory_stability_spec.rb` + +- [ ] **Step 1: Write the spec file with all memory stability tests** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Memory stability integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + # ── Pool count stays bounded under max_total_connections ─────────── + context 'bounded pool count', + skip: (V4IntegrationHelper.sqlite? ? 'SQLite pool-per-tenant less meaningful with single-writer lock' : false) do + let(:tmp_dir) { Dir.mktmpdir('apartment_mem_bounded') } + let(:tenants) { Array.new(20) { |i| "mem_bounded_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.pool_idle_timeout = 300 + c.max_total_connections = 5 + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + it 'pool count stays within max_total_connections after reaper cycles' do + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + 3.times do |cycle| + tenants.each do |t| + Apartment::Tenant.switch(t) do + Widget.create!(name: "cycle_#{cycle}") + end + end + + Apartment.pool_reaper.run_cycle + + pool_count = Apartment.pool_manager.stats[:total_pools] + expect(pool_count).to(be <= 5), + "Cycle #{cycle}: expected <= 5 pools, got #{pool_count}" + end + end + end + + # ── Repeated create/drop doesn't leak pools ──────────────────────── + context 'create/drop cycle', + skip: (V4IntegrationHelper.sqlite? ? 'SQLite pool-per-tenant less meaningful with single-writer lock' : false) do + let(:tmp_dir) { Dir.mktmpdir('apartment_mem_cycle') } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { [] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + end + + after do + Apartment.clear_config + Apartment::Current.reset + end + + it 'pool count returns to baseline after 20 create/drop cycles' do + baseline = Apartment.pool_manager.stats[:total_pools] + + 20.times do |i| + tenant = "ephemeral_#{i}" + Apartment.adapter.create(tenant) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute('SELECT 1') + end + Apartment.adapter.drop(tenant) + end + + final = Apartment.pool_manager.stats[:total_pools] + expect(final).to(be <= baseline + 1), + "Expected pool count near baseline #{baseline}, got #{final}" + end + end + + # ── Sustained switching without pool growth ───────────────────────── + context 'sustained switching' do + let(:tmp_dir) { Dir.mktmpdir('apartment_mem_sustained') } + let(:tenants) { Array.new(5) { |i| "sustained_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.max_total_connections = 100 + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'no phantom pools after 200 round-robin switches' do + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + # Prime all tenant pools + tenants.each do |t| + Apartment::Tenant.switch(t) { Widget.create!(name: 'prime') } + end + + expected_pools = Apartment.pool_manager.stats[:total_pools] + + 200.times do |i| + tenant = tenants[i % tenants.size] + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute('SELECT 1') + end + end + + final_pools = Apartment.pool_manager.stats[:total_pools] + expect(final_pools).to(eq(expected_pools)), + "Expected #{expected_pools} pools after 200 switches, got #{final_pools}" + end + end +end +``` + +- [ ] **Step 2: Run the spec on SQLite (sustained switching context) and PG (all contexts)** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/memory_stability_spec.rb --format documentation` + +Expected: The two PG/MySQL-only contexts skip on SQLite. The "sustained switching" context passes. + +Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/memory_stability_spec.rb --format documentation` + +Expected: All 3 examples pass. + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/memory_stability_spec.rb +git commit -m "Add memory stability integration spec + +Proves pool count stays bounded under max_total_connections, +create/drop cycles don't leak pools, and sustained round-robin +switching doesn't create phantom pool entries." +``` + +--- + +### Task 3: Harden `stress_spec.rb` with two new `it` blocks + +**Files:** +- Modify: `spec/integration/v4/stress_spec.rb:106` (insert before the closing `end` of `concurrent switching` context) + +- [ ] **Step 1: Add the two new tests after line 106 in the `concurrent switching` context** + +Insert the following two `it` blocks after `stress_spec.rb:106` (after the existing `concurrent pool creation` test, before the `end` that closes the `concurrent switching` context): + +```ruby + it 'each thread sees correct Tenant.current inside switch block' do + barrier = Concurrent::CyclicBarrier.new(5) + results = Concurrent::Map.new + errors = Queue.new + + threads = tenants.map.with_index do |tenant, idx| + Thread.new do + barrier.wait + Apartment::Tenant.switch(tenant) do + results[idx] = { + tenant_current: Apartment::Tenant.current, + current_tenant: Apartment::Current.tenant, + } + end + rescue StandardError => e + errors << "Thread #{idx}: #{e.class}: #{e.message}" + end + end + threads.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + tenants.each_with_index do |tenant, idx| + expect(results[idx]).not_to(be_nil, "Thread #{idx} produced no result") + expect(results[idx][:tenant_current]).to(eq(tenant), + "Thread #{idx}: Tenant.current was '#{results[idx][:tenant_current]}', expected '#{tenant}'") + expect(results[idx][:current_tenant]).to(eq(tenant), + "Thread #{idx}: Current.tenant was '#{results[idx][:current_tenant]}', expected '#{tenant}'") + end + end + + it 'cross-tenant connection checkout returns only own tenant data' do + barrier = Concurrent::CyclicBarrier.new(2) + results = Concurrent::Map.new + errors = Queue.new + + %w[stress_0 stress_1].each_with_index do |tenant, idx| + Thread.new do + barrier.wait + Apartment::Tenant.switch(tenant) do + Widget.create!(name: "isolation_#{idx}") + results[idx] = Widget.pluck(:name) + end + rescue StandardError => e + errors << "Thread #{idx}: #{e.class}: #{e.message}" + end + end.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + expect(results[0]).to(include('isolation_0')) + expect(results[0]).not_to(include('isolation_1'), + "Thread 0 (stress_0) read data from stress_1's pool") + expect(results[1]).to(include('isolation_1')) + expect(results[1]).not_to(include('isolation_0'), + "Thread 1 (stress_1) read data from stress_0's pool") + end +``` + +The insertion point is between line 106 (`end` closing the `concurrent pool creation` test) and line 107 (`end` closing the `concurrent switching` context). Both new `it` blocks go inside the `concurrent switching` context, which shares the `before` block that creates 5 tenants with a bumped pool size of 15 and `Widget` stub_const. + +- [ ] **Step 2: Run the stress spec to verify all examples pass (including existing ones)** + +Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/stress_spec.rb --format documentation` + +Expected: All examples pass including the two new ones. Existing examples unaffected. + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/stress_spec.rb +git commit -m "Harden stress spec with tenant identity and isolation assertions + +Add two new examples to the concurrent switching context: +- Explicit Tenant.current + Current.tenant check per thread +- Cross-tenant connection checkout isolation with dedicated tenants + and barrier synchronization" +``` + +--- + +### Task 4: Create `cli_integration_spec.rb` + +**Files:** +- Create: `spec/integration/v4/cli_integration_spec.rb` + +- [ ] **Step 1: Write the spec file with all CLI integration tests** + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative '../../../lib/apartment/cli' + +RSpec.describe('v4 CLI integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_cli') } + let(:tenants) { %w[cli_alpha cli_beta cli_gamma] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + end + + after do + tenants.each do |t| + Apartment.adapter.drop(t) + rescue StandardError + nil + end + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + describe 'tenants list' do + it 'lists all tenants from tenants_provider' do + output = capture_stdout { Apartment::CLI::Tenants.new.invoke(:list) } + + tenants.each do |t| + expect(output).to(include(t), "Expected '#{t}' in list output") + end + end + end + + describe 'tenants create' do + it 'creates a tenant accessible via switch' do + capture_stdout { Apartment::CLI::Tenants.new.invoke(:create, ['cli_alpha']) } + + Apartment::Tenant.switch('cli_alpha') do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + ActiveRecord::Base.connection.execute('SELECT 1') + end + end + end + + describe 'tenants drop' do + it 'drops a tenant so it no longer exists' do + Apartment.adapter.create('cli_alpha') + + ENV['APARTMENT_FORCE'] = '1' + capture_stdout { Apartment::CLI::Tenants.new.invoke(:drop, ['cli_alpha']) } + ENV.delete('APARTMENT_FORCE') + + expect do + Apartment.adapter.drop('cli_alpha') + end.to(raise_error(Apartment::TenantNotFound)) + end + end + + describe 'pool stats' do + it 'displays pool count and tenant names' do + Apartment.adapter.create('cli_alpha') + Apartment::Tenant.switch('cli_alpha') do + ActiveRecord::Base.connection.execute('SELECT 1') + end + + output = capture_stdout { Apartment::CLI::Pool.new.invoke(:stats) } + + expect(output).to(include('Total pools:')) + expect(output).to(include('cli_alpha')) + end + end + + private + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end +``` + +- [ ] **Step 2: Run the spec on SQLite** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/cli_integration_spec.rb --format documentation` + +Expected: All 4 examples pass. + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/cli_integration_spec.rb +git commit -m "Add CLI integration spec + +Tests Thor CLI commands (tenants list/create/drop, pool stats) +against a real database. Uses APARTMENT_FORCE=1 to bypass +confirmation prompt on drop." +``` + +--- + +### Task 5: Drive-by fix — update `coverage_gaps_spec.rb` to use public `run_cycle` API + +**Files:** +- Modify: `spec/integration/v4/coverage_gaps_spec.rb:164-166` + +- [ ] **Step 1: Replace `send(:reap)` with `run_cycle`** + +Change line 166 from: + +```ruby + Apartment.pool_reaper.send(:reap) +``` + +to: + +```ruby + Apartment.pool_reaper.run_cycle +``` + +Also update the comment on lines 164-165 from: + +```ruby + # Directly invoke reap to avoid timing-dependent background thread. + # PoolReaper#reap is private — we test the observable effect. +``` + +to: + +```ruby + # Directly invoke run_cycle to avoid timing-dependent background thread. +``` + +- [ ] **Step 2: Run the coverage gaps spec to verify it still passes** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/coverage_gaps_spec.rb --format documentation` + +Expected: All examples pass. The LRU eviction test uses `run_cycle` now. + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/coverage_gaps_spec.rb +git commit -m "Use public run_cycle API instead of send(:reap) + +Drive-by fix: PoolReaper#reap is private and delegates to +run_cycle. Use the public API directly." +``` + +--- + +### Task 6: Run full integration suite across all engines + +**Files:** None (verification only) + +- [ ] **Step 1: Run SQLite integration suite** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ --format progress` + +Expected: All non-skipped examples pass. Stress spec skips on SQLite (expected). Memory stability bounded/create-drop contexts skip on SQLite (expected). Fiber safety and CLI specs pass. + +- [ ] **Step 2: Run PostgreSQL integration suite** + +Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ --format progress` + +Expected: All examples pass including stress, memory stability, fiber safety, and CLI. + +- [ ] **Step 3: Run MySQL integration suite (if MySQL available locally)** + +Run: `DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ --format progress` + +Expected: All examples pass. If MySQL is not available locally, this step can be deferred to CI. + +- [ ] **Step 4: Run rubocop on all changed files** + +Run: `bundle exec rubocop spec/integration/v4/fiber_safety_spec.rb spec/integration/v4/memory_stability_spec.rb spec/integration/v4/cli_integration_spec.rb spec/integration/v4/stress_spec.rb spec/integration/v4/coverage_gaps_spec.rb` + +Expected: No offenses. If ThreadSafety/NewThread fires on fiber_safety_spec.rb, add a rubocop:disable directive at the top (same pattern as stress_spec.rb). + +- [ ] **Step 5: Run unit tests to verify no regressions** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/ --format progress` + +Expected: All unit tests pass unchanged. diff --git a/docs/plans/apartment-v4/phase-7.1-excluded-models-pin-tenant.md b/docs/plans/apartment-v4/phase-7.1-excluded-models-pin-tenant.md new file mode 100644 index 00000000..457b21b0 --- /dev/null +++ b/docs/plans/apartment-v4/phase-7.1-excluded-models-pin-tenant.md @@ -0,0 +1,1033 @@ +# Phase 7.1: Excluded Models Fix + Apartment::Model Concern — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix excluded model isolation for database-per-tenant strategies and introduce `Apartment::Model` with `pin_tenant` as the primary API, deprecating `config.excluded_models`. + +**Architecture:** Explicit model-level `pin_tenant` declaration registers models in `Apartment.pinned_models` (a `Concurrent::Set`). `ConnectionHandling#connection_pool` checks this registry via `Apartment.pinned_model?` (ancestor walk) to skip tenant pool routing for pinned models. `process_excluded_models` is renamed to `process_pinned_models` and hardened with table existence validation. `config.excluded_models` is preserved as a deprecated shim. + +**Tech Stack:** Ruby, ActiveRecord, ActiveSupport::Concern, Concurrent::Set, RSpec + +**Design spec:** `docs/designs/v4-phase7.1-excluded-models-pin-tenant.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `lib/apartment/concerns/model.rb` | Create | `Apartment::Model` concern with `pin_tenant`, `apartment_pinned?` | +| `lib/apartment.rb` | Modify | `pinned_models`, `register_pinned_model`, `pinned_model?`, `activated?`, `process_pinned_model`, `clear_config` reset | +| `lib/apartment/patches/connection_handling.rb` | Modify | Early return for pinned models | +| `lib/apartment/adapters/abstract_adapter.rb` | Modify | `process_pinned_models`, `process_pinned_model`, deprecation alias | +| `lib/apartment/tenant.rb` | Modify | `init` calls `process_pinned_models` | +| `lib/apartment/railtie.rb` | Modify | Set `@activated` in `activate!` | +| `lib/apartment/config.rb` | Modify | Deprecation warning on `excluded_models=` | +| `spec/unit/concerns/model_spec.rb` | Create | Unit tests for `Apartment::Model` | +| `spec/unit/adapters/abstract_adapter_spec.rb` | Modify | Update `process_excluded_models` tests | +| `spec/integration/v4/excluded_models_spec.rb` | Modify | Remove pending guards, add new specs | + +--- + +### Task 1: `Apartment::Model` Concern + Unit Tests + +**Files:** +- Create: `lib/apartment/concerns/model.rb` +- Create: `spec/unit/concerns/model_spec.rb` + +- [ ] **Step 1: Write the failing test for `pin_tenant` registration** + +Create `spec/unit/concerns/model_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/concerns/model' + +RSpec.describe(Apartment::Model) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + end + + after do + Apartment.clear_config + end + + describe '.pin_tenant' do + it 'registers the model in Apartment.pinned_models' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedTestModel', klass) + + klass.pin_tenant + + expect(Apartment.pinned_models).to(include(PinnedTestModel)) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/concerns/model_spec.rb -v` +Expected: FAIL — `Apartment::Model` not defined or `Apartment.pinned_models` not defined. + +- [ ] **Step 3: Implement `Apartment::Model` concern** + +Create `lib/apartment/concerns/model.rb`: + +```ruby +# frozen_string_literal: true + +require 'active_support/concern' + +module Apartment + module Model + extend ActiveSupport::Concern + + class_methods do + # Declare this model as pinned to the default tenant. + # Pinned models bypass tenant switching in ConnectionHandling — + # their connection always targets the default tenant's database/schema. + # + # Safe to call before or after Apartment.activate!. + # Idempotent: no-op if this class (or a parent) is already pinned. + def pin_tenant + return if apartment_pinned? + + Apartment.register_pinned_model(self) + @apartment_pinned = true + + # If Apartment is already activated, process immediately (Zeitwerk autoload path). + # Otherwise, activate! will process all registered models. + Apartment.process_pinned_model(self) if Apartment.activated? + end + + def apartment_pinned? + return true if @apartment_pinned == true + return false unless superclass.respond_to?(:apartment_pinned?) + + superclass.apartment_pinned? + end + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bundle exec rspec spec/unit/concerns/model_spec.rb -v` +Expected: Fails because `Apartment.pinned_models` doesn't exist yet. That's expected — Task 2 adds it. + +- [ ] **Step 5: Write remaining unit tests for `pin_tenant`** + +Add to `spec/unit/concerns/model_spec.rb`: + +```ruby + describe '.pin_tenant' do + # ... existing test ... + + it 'is idempotent — second call is a no-op' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('IdempotentModel', klass) + + klass.pin_tenant + klass.pin_tenant + + expect(Apartment.pinned_models.count { |m| m == IdempotentModel }).to(eq(1)) + end + + it 'processes immediately when Apartment is already activated' do + expect(Apartment).to(receive(:activated?).and_return(true)) + expect(Apartment).to(receive(:process_pinned_model)) + + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('LateLoadedModel', klass) + + klass.pin_tenant + end + + it 'defers processing when Apartment is not yet activated' do + expect(Apartment).to(receive(:activated?).and_return(false)) + expect(Apartment).not_to(receive(:process_pinned_model)) + + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('EarlyModel', klass) + + klass.pin_tenant + end + end + + describe '.apartment_pinned?' do + it 'returns false for unpinned models' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + + expect(klass.apartment_pinned?).to(be(false)) + end + + it 'returns true after pin_tenant' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedCheck', klass) + + klass.pin_tenant + expect(klass.apartment_pinned?).to(be(true)) + end + + it 'returns true for subclass of pinned model (STI)' do + parent = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedParent', parent) + parent.pin_tenant + + child = Class.new(parent) + stub_const('PinnedChild', child) + + expect(child.apartment_pinned?).to(be(true)) + end + + it 'returns false for classes without the concern' do + klass = Class.new(ActiveRecord::Base) + + expect(klass.respond_to?(:apartment_pinned?)).to(be(false)) + end + end +``` + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/concerns/model.rb spec/unit/concerns/model_spec.rb +git commit -m "feat: add Apartment::Model concern with pin_tenant + +Introduces model-level declaration for pinning models to the default +tenant. Replaces centralized config.excluded_models with a Rails- +idiomatic include + DSL pattern." +``` + +--- + +### Task 2: `Apartment` Module Registry Methods + +**Files:** +- Modify: `lib/apartment.rb:31-81` (class << self block) + +- [ ] **Step 1: Write the failing test for `pinned_models` and `pinned_model?`** + +These are tested indirectly through the concern tests from Task 1. Verify the Task 1 tests now pass after adding the module methods. + +- [ ] **Step 2: Add registry methods to `lib/apartment.rb`** + +Add inside `class << self`, after the `adapter` method (line 40) and before `configure`: + +```ruby + # Registry of models that declared pin_tenant. + # Uses Concurrent::Set for thread safety (Zeitwerk autoload in threaded servers). + def pinned_models + @pinned_models ||= Concurrent::Set.new + end + + def register_pinned_model(klass) + pinned_models.add(klass) + end + + # Check if a class (or any of its ancestors) is a pinned model. + # Used by ConnectionHandling to skip tenant pool routing. + def pinned_model?(klass) + klass.ancestors.any? { |a| a.is_a?(Class) && pinned_models.include?(a) } + end + + def activated? + @activated == true + end + + def process_pinned_model(klass) + adapter&.process_pinned_model(klass) + end +``` + +Add `require 'concurrent'` at the top of the file (after `require 'active_support/current_attributes'`). Note: `concurrent-ruby` is already a transitive dependency via ActiveSupport. + +- [ ] **Step 3: Update `clear_config` to reset pinned state** + +In `lib/apartment.rb`, update `clear_config`: + +```ruby + def clear_config + teardown_old_state + @config = nil + @pool_manager = nil + @pool_reaper = nil + @pinned_models = nil + @activated = false + end +``` + +- [ ] **Step 4: Update `activate!` to set `@activated`** + +In `lib/apartment.rb`, update `activate!`: + +```ruby + def activate! + require_relative('apartment/patches/connection_handling') + ActiveRecord::Base.singleton_class.prepend(Patches::ConnectionHandling) + @activated = true + end +``` + +- [ ] **Step 5: Run concern tests to verify they pass** + +Run: `bundle exec rspec spec/unit/concerns/model_spec.rb -v` +Expected: All pass. + +- [ ] **Step 6: Run full unit suite to check for regressions** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All existing tests pass. `clear_config` resets pinned state. + +- [ ] **Step 7: Commit** + +```bash +git add lib/apartment.rb +git commit -m "feat: add pinned_models registry to Apartment module + +Concurrent::Set registry for pin_tenant models. pinned_model? walks +ancestors for STI support. clear_config resets pinned state. +activate! sets @activated flag for deferred/immediate processing." +``` + +--- + +### Task 3: `ConnectionHandling` Pinned Model Guard + +**Files:** +- Modify: `lib/apartment/patches/connection_handling.rb:16-19` + +- [ ] **Step 1: Write the failing unit test** + +The existing `spec/apartment/patches/connection_handling_spec.rb` or integration tests should cover this. First, check if a unit spec exists: + +Run: `ls spec/unit/patches/ 2>/dev/null || ls spec/apartment/patches/ 2>/dev/null || echo "no existing spec"` + +If no unit spec exists, the integration tests in Task 8 will cover this. For now, write a focused unit test in a new file `spec/unit/patches/connection_handling_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/patches/connection_handling' +require_relative '../../../lib/apartment/concerns/model' + +RSpec.describe(Apartment::Patches::ConnectionHandling) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + Apartment.activate! + end + + after do + Apartment.clear_config + Apartment::Current.reset + end + + describe 'pinned_model? registry check' do + it 'returns true for a pinned model' do + pinned_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedGlobal', pinned_class) + pinned_class.pin_tenant + + expect(Apartment.pinned_model?(PinnedGlobal)).to(be(true)) + end + + it 'returns true for STI subclass of a pinned model' do + parent = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedParentModel', parent) + parent.pin_tenant + + child = Class.new(parent) + stub_const('PinnedChildModel', child) + + expect(Apartment.pinned_model?(PinnedChildModel)).to(be(true)) + end + + it 'returns false for normal tenant models' do + tenant_class = Class.new(ActiveRecord::Base) + stub_const('TenantWidget', tenant_class) + + expect(Apartment.pinned_model?(TenantWidget)).to(be(false)) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails or baseline** + +Run: `bundle exec rspec spec/unit/patches/connection_handling_spec.rb -v` + +- [ ] **Step 3: Add pinned model guard to `ConnectionHandling`** + +In `lib/apartment/patches/connection_handling.rb`, add after line 18 (`return super unless Apartment.pool_manager`): + +```ruby + # Skip tenant override for Apartment pinned models. + # Uses explicit registry (not connection_specification_name heuristic) + # because ApplicationRecord subclasses have a different spec name than + # ActiveRecord::Base while sharing the same pool. + return super if self != ActiveRecord::Base && Apartment.pinned_model?(self) +``` + +The full method now reads (lines 12-20): + +```ruby + def connection_pool + tenant = Apartment::Current.tenant + cfg = Apartment.config + + return super if tenant.nil? || cfg.nil? + return super if tenant.to_s == cfg.default_tenant.to_s + return super unless Apartment.pool_manager + + # Skip tenant override for Apartment pinned models. + return super if self != ActiveRecord::Base && Apartment.pinned_model?(self) + + role = ActiveRecord::Base.current_role + # ... rest unchanged +``` + +- [ ] **Step 4: Run tests** + +Run: `bundle exec rspec spec/unit/patches/connection_handling_spec.rb spec/unit/ -v` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/patches/connection_handling.rb spec/unit/patches/connection_handling_spec.rb +git commit -m "feat: ConnectionHandling skips tenant routing for pinned models + +Adds explicit registry check via Apartment.pinned_model? after the +existing nil/default/pool_manager guards. Uses ancestor walk instead +of connection_specification_name to avoid ApplicationRecord false +positives." +``` + +--- + +### Task 4: `process_pinned_models` in AbstractAdapter + +**Files:** +- Modify: `lib/apartment/adapters/abstract_adapter.rb:99-116` +- Modify: `spec/unit/adapters/abstract_adapter_spec.rb:322-406` + +- [ ] **Step 1: Write the failing test for `process_pinned_models`** + +Add to `spec/unit/adapters/abstract_adapter_spec.rb`, after the existing `#process_excluded_models` block: + +```ruby + describe '#process_pinned_models' do + it 'establishes connections for each pinned model' do + model_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedSetting', model_class) + allow(model_class).to(receive(:table_name).and_return('pinned_settings')) + allow(model_class).to(receive(:table_name=)) + + PinnedSetting.pin_tenant + + expected_config = { 'adapter' => 'postgresql', 'database' => 'public' } + expect(model_class).to(receive(:establish_connection)) do |arg| + expect(arg).to(eq(expected_config)) + end + + adapter.process_pinned_models + end + + it 'skips models already processed (idempotent)' do + model_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('AlreadyPinned', model_class) + allow(model_class).to(receive(:table_name).and_return('already_pinned')) + allow(model_class).to(receive(:table_name=)) + + AlreadyPinned.pin_tenant + + # First call processes the model + allow(model_class).to(receive(:establish_connection)) + adapter.process_pinned_models + + # Second call skips — @apartment_connection_established is set + expect(model_class).not_to(receive(:establish_connection)) + adapter.process_pinned_models + end + + it 'prefixes table name with default schema for schema strategy' do + model_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('SchemaPinned', model_class) + allow(model_class).to(receive(:establish_connection)) + allow(model_class).to(receive(:table_name).and_return('schema_pinned')) + + SchemaPinned.pin_tenant + + expect(model_class).to(receive(:table_name=).with('public.schema_pinned')) + adapter.process_pinned_models + end + + it 'does nothing when no models are pinned' do + expect { adapter.process_pinned_models }.not_to(raise_error) + end + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb -e 'process_pinned_models' -v` +Expected: FAIL — `process_pinned_models` not defined. + +- [ ] **Step 3: Implement `process_pinned_models` and `process_pinned_model`** + +Replace the `process_excluded_models` method in `lib/apartment/adapters/abstract_adapter.rb` (lines 99-116): + +```ruby + # Process all pinned models — establish separate connections pinned to default tenant. + def process_pinned_models + return if Apartment.pinned_models.empty? + + Apartment.pinned_models.each do |klass| + process_pinned_model(klass) + end + end + + # Process a single pinned model. Called by process_pinned_models (batch) + # and by Apartment::Model.pin_tenant (when activated? is true). + def process_pinned_model(klass) + # Idempotent: skip if already processed. Uses a class-level flag rather + # than connection_specification_name comparison — the spec name differs + # from ActiveRecord::Base for ApplicationRecord subclasses even before + # establish_connection, so it's not a reliable "already processed" signal. + return if klass.instance_variable_get(:@apartment_connection_established) + + default_config = resolve_connection_config(Apartment.config.default_tenant) + klass.establish_connection(default_config) + klass.instance_variable_set(:@apartment_connection_established, true) + + if Apartment.config.tenant_strategy == :schema + table = klass.table_name.split('.').last + klass.table_name = "#{default_tenant}.#{table}" + end + end + + # Deprecated: use process_pinned_models instead. + # Models registered via config.excluded_models are resolved and registered + # as pinned models during activate! (see Railtie / Tenant.init). + def process_excluded_models + warn '[Apartment] DEPRECATION: process_excluded_models is deprecated. ' \ + 'Use Apartment::Model with pin_tenant instead.' + process_pinned_models + end +``` + +Also remove the now-unused `resolve_excluded_model` private method (lines 181-185) — its functionality moves to the `excluded_models` shim in Task 5. + +- [ ] **Step 4: Run tests** + +Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb -v` +Expected: New `process_pinned_models` tests pass. Existing `process_excluded_models` tests may fail — they reference the old implementation. Update them in step 5. + +- [ ] **Step 5: Update existing `process_excluded_models` tests** + +The existing tests in `#process_excluded_models` (lines 322-406) should continue to work via the deprecated `config.excluded_models` shim (Task 5 wires that). For now, update the describe block to note the deprecation and verify the shim emits a warning: + +Replace the existing `describe '#process_excluded_models'` block with: + +```ruby + describe '#process_excluded_models (deprecated)' do + it 'emits a deprecation warning' do + reconfigure(excluded_models: []) + expect { adapter.process_excluded_models } + .to(output(/DEPRECATION.*process_excluded_models/).to_stderr_from_all_processes) + end + + it 'delegates to process_pinned_models' do + reconfigure(excluded_models: []) + expect(adapter).to(receive(:process_pinned_models)) + adapter.process_excluded_models + end + end +``` + +- [ ] **Step 6: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add lib/apartment/adapters/abstract_adapter.rb spec/unit/adapters/abstract_adapter_spec.rb +git commit -m "feat: replace process_excluded_models with process_pinned_models + +Iterates Apartment.pinned_models registry instead of config string list. +Idempotent: skips models with existing connections. Deprecation alias +for process_excluded_models." +``` + +--- + +### Task 5: `config.excluded_models` Deprecation Shim + `Tenant.init` Update + +**Files:** +- Modify: `lib/apartment/config.rb:17` +- Modify: `lib/apartment/tenant.rb:41-42` +- Modify: `lib/apartment/railtie.rb:20-21` + +- [ ] **Step 1: Write the failing test for the deprecation warning** + +Add to `spec/unit/adapters/abstract_adapter_spec.rb` or a new config spec: + +```ruby + describe 'config.excluded_models deprecation shim' do + it 'resolves excluded model strings and registers them as pinned' do + model_class = Class.new(ActiveRecord::Base) + stub_const('DeprecatedExcluded', model_class) + allow(model_class).to(receive(:table_name).and_return('deprecated_excluded')) + allow(model_class).to(receive(:table_name=)) + allow(model_class).to(receive(:establish_connection)) + allow(model_class).to(receive(:connection_specification_name).and_return('ActiveRecord::Base')) + + reconfigure(excluded_models: ['DeprecatedExcluded']) + + Apartment::Tenant.init + + expect(Apartment.pinned_models).to(include(DeprecatedExcluded)) + end + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb -e 'deprecation shim' -v` +Expected: FAIL — `Tenant.init` still calls old `process_excluded_models`. + +- [ ] **Step 3: Update `Tenant.init` to resolve excluded_models and call `process_pinned_models`** + +In `lib/apartment/tenant.rb`, replace `init`: + +```ruby + # Initialize: resolve excluded_models shim, then process pinned models. + def init + resolve_excluded_models_shim + adapter.process_pinned_models + end + + private + + def adapter + Apartment.adapter or + raise(ConfigurationError, 'Apartment adapter not configured. Call Apartment.configure first.') + end + + # Resolve config.excluded_models strings into pinned model registrations. + # This is the deprecated compatibility path — new code should use + # `include Apartment::Model` + `pin_tenant` in each model. + def resolve_excluded_models_shim + return if Apartment.config.excluded_models.empty? + + Apartment.config.excluded_models.each do |model_name| + klass = model_name.constantize + next if Apartment.pinned_models.include?(klass) + + if klass.respond_to?(:apartment_pinned?) && klass.apartment_pinned? + warn "[Apartment] WARNING: #{model_name} is in config.excluded_models " \ + 'AND declares pin_tenant. Remove it from excluded_models.' + next + end + + Apartment.register_pinned_model(klass) + rescue NameError => e + raise(Apartment::ConfigurationError, + "Excluded model '#{model_name}' could not be resolved: #{e.message}") + end + end +``` + +- [ ] **Step 4: Add deprecation warning on `excluded_models=` setter** + +In `lib/apartment/config.rb`, replace the `attr_accessor :excluded_models` with a custom setter. Change line 17: + +Remove `excluded_models` from the `attr_accessor` line and add: + +```ruby + attr_reader :excluded_models +``` + +Add after `initialize`: + +```ruby + def excluded_models=(list) + unless list.empty? + warn '[Apartment] DEPRECATION: config.excluded_models is deprecated and will be ' \ + "removed in v5. Use `include Apartment::Model` and `pin_tenant` in each model instead.\n" \ + 'For third-party gem models, use config.excluded_models as a transitional escape hatch.' + end + @excluded_models = list + end +``` + +- [ ] **Step 5: Run tests** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass. Existing tests that set `excluded_models: []` won't trigger the warning (empty list guard). + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/tenant.rb lib/apartment/config.rb +git commit -m "feat: deprecation shim for config.excluded_models + +Tenant.init resolves excluded_models strings into pinned model +registrations before calling process_pinned_models. Deprecation +warning on non-empty excluded_models= setter. Duplicate detection +warns if model uses both paths." +``` + +--- + +### Task 6: Railtie `activated?` Flag + +**Files:** +- Modify: `lib/apartment/railtie.rb:19-22` + +- [ ] **Step 1: Verify `activated?` is already set in `activate!`** + +Task 2 added `@activated = true` to `Apartment.activate!`. The Railtie calls `Apartment.activate!` then `Apartment::Tenant.init`. This ordering is correct — `activate!` sets the flag, then `init` processes pinned models. No Railtie changes needed beyond what Task 2 already did. + +- [ ] **Step 2: Verify boot order in Railtie** + +Read `lib/apartment/railtie.rb` and confirm the call sequence is: +1. `Apartment.activate!` (prepends ConnectionHandling, sets `@activated = true`) +2. `Apartment::Tenant.init` (resolves excluded_models shim, calls `process_pinned_models`) + +This is already the correct order in the existing Railtie (lines 20-21). + +- [ ] **Step 3: Run unit tests** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass. + +- [ ] **Step 4: Commit (only if Railtie needed changes)** + +If no changes were needed, skip this commit. + +--- + +### Task 7: Integration Tests — Core Pinned Model Specs + +**Files:** +- Modify: `spec/integration/v4/excluded_models_spec.rb` + +- [ ] **Step 1: Update existing spec to use `pin_tenant` instead of `config.excluded_models`** + +Rewrite `spec/integration/v4/excluded_models_spec.rb`. The key changes: +- `GlobalSetting` uses `include Apartment::Model` + `pin_tenant` instead of `config.excluded_models` +- Remove all `pending` guards for non-PG engines +- Add `ApplicationRecord` abstract class for realistic topology +- Keep `Widget` as a normal tenant model + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require 'apartment/concerns/model' + +RSpec.describe('v4 Pinned models integration (Apartment::Model)', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_pinned') } + let(:created_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + # Create tables in default database + ActiveRecord::Base.connection.create_table(:global_settings, force: true) do |t| + t.string(:key) + t.string(:value) + end + V4IntegrationHelper.create_test_table! + + # Simulate ApplicationRecord for realistic topology + stub_const('ApplicationRecord', Class.new(ActiveRecord::Base) { + self.abstract_class = true + }) + + stub_const('GlobalSetting', Class.new(ApplicationRecord) { + self.table_name = 'global_settings' + include Apartment::Model + pin_tenant + }) + + stub_const('Widget', Class.new(ApplicationRecord) { + self.table_name = 'widgets' + }) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { %w[tenant_a] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + Apartment.adapter.process_pinned_models + + Apartment.adapter.create('tenant_a') + created_tenants << 'tenant_a' + Apartment::Tenant.switch('tenant_a') do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(created_tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'pin_tenant establishes a dedicated connection for the model' do + expect(GlobalSetting.connection_specification_name).not_to(eq(ActiveRecord::Base.connection_specification_name)) + end + + it 'pin_tenant is idempotent' do + expect { Apartment.adapter.process_pinned_models }.not_to(raise_error) + expect(GlobalSetting.connection_specification_name).not_to(eq(ActiveRecord::Base.connection_specification_name)) + end + + it 'pinned model queries always target the default database' do + GlobalSetting.create!(key: 'site_name', value: 'TestSite') + + Apartment::Tenant.switch('tenant_a') do + expect(GlobalSetting.count).to(eq(1)) + expect(GlobalSetting.first.key).to(eq('site_name')) + expect(Widget.count).to(eq(0)) + end + end + + it 'pinned model data persists across tenant switches' do + GlobalSetting.create!(key: 'version', value: '1.0') + + Apartment::Tenant.switch('tenant_a') do + expect(GlobalSetting.find_by(key: 'version').value).to(eq('1.0')) + end + + expect(GlobalSetting.count).to(eq(1)) + end + + it 'pinned model writes inside a tenant block land in the default database' do + Apartment::Tenant.switch('tenant_a') do + GlobalSetting.create!(key: 'inside_tenant', value: 'yes') + end + + expect(GlobalSetting.find_by(key: 'inside_tenant')).to(be_present) + end + + it 'tenant model (Widget) still routes through tenant pool during switch' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'in_tenant') + expect(Widget.count).to(eq(1)) + end + + # Back in default — tenant widget not visible (different database/schema) + # For PG schema, public.widgets might exist; for DB-per-tenant, no widgets table in default + if V4IntegrationHelper.postgresql? + # Schema strategy: widgets table exists in public, should be empty + expect(Widget.count).to(eq(0)) + end + end + + context 'ApplicationRecord topology' do + it 'normal models inheriting from ApplicationRecord get tenant routing' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'routed_correctly') + expect(Widget.count).to(eq(1)) + end + end + end + + context 'STI subclass of pinned model' do + before do + stub_const('AdminSetting', Class.new(GlobalSetting)) + end + + it 'inherits pinned behavior' do + AdminSetting.create!(key: 'admin_only', value: 'true') + + Apartment::Tenant.switch('tenant_a') do + expect(AdminSetting.find_by(key: 'admin_only').value).to(eq('true')) + end + end + end + + context 'config.excluded_models shim' do + it 'still works via deprecated path' do + # Re-setup with config.excluded_models instead of pin_tenant + stub_const('LegacySetting', Class.new(ApplicationRecord) { + self.table_name = 'global_settings' + }) + + Apartment.clear_config + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { %w[tenant_a] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.excluded_models = ['LegacySetting'] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + Apartment::Tenant.init + + expect(Apartment.pinned_models).to(include(LegacySetting)) + + LegacySetting.create!(key: 'legacy', value: 'works') + Apartment::Tenant.switch('tenant_a') do + expect(LegacySetting.find_by(key: 'legacy').value).to(eq('works')) + end + end + end + + context 'concurrent pinned model access', :stress do + it 'two threads in different tenants both read/write the pinned model to default' do + GlobalSetting.create!(key: 'shared', value: 'initial') + + threads = 2.times.map do |i| + Thread.new do + Apartment::Tenant.switch('tenant_a') do + GlobalSetting.create!(key: "thread_#{i}", value: "val_#{i}") + sleep(0.01) # brief yield to increase interleaving + GlobalSetting.find_by(key: "thread_#{i}") + end + end + end + + threads.each(&:join) + expect(GlobalSetting.count).to(eq(3)) # initial + 2 threads + end + end +end +``` + +- [ ] **Step 2: Run integration tests on SQLite** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/excluded_models_spec.rb -v` +Expected: All pass — this is the main proof that the `ConnectionHandling` fix works for database-per-tenant. + +- [ ] **Step 3: Run integration tests on PostgreSQL (if available)** + +Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/excluded_models_spec.rb -v` +Expected: All pass. + +- [ ] **Step 4: Run integration tests on MySQL (if available)** + +Run: `DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/excluded_models_spec.rb -v` +Expected: All pass. + +- [ ] **Step 5: Run full integration suite to check for regressions** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ -v` +Expected: All pass. + +- [ ] **Step 6: Commit** + +```bash +git add spec/integration/v4/excluded_models_spec.rb +git commit -m "test: comprehensive pinned model integration tests + +Covers all strategies (schema, database-per-tenant), ApplicationRecord +topology, STI inheritance, config.excluded_models shim, concurrent +access. Removes pending guards for non-PG engines." +``` + +--- + +### Task 8: Rubocop + Final Verification + +**Files:** +- All changed files + +- [ ] **Step 1: Run rubocop on all changed files** + +Run: `bundle exec rubocop lib/apartment/concerns/model.rb lib/apartment.rb lib/apartment/patches/connection_handling.rb lib/apartment/adapters/abstract_adapter.rb lib/apartment/tenant.rb lib/apartment/config.rb spec/unit/concerns/model_spec.rb spec/unit/patches/connection_handling_spec.rb spec/unit/adapters/abstract_adapter_spec.rb spec/integration/v4/excluded_models_spec.rb` + +Fix any offenses. + +- [ ] **Step 2: Run full unit test suite** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass. + +- [ ] **Step 3: Run full integration suite across engines** + +Run: +```bash +bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ --format progress +DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ --format progress +DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ --format progress +``` +Expected: All pass. + +- [ ] **Step 4: Fix any failures and commit fixes** + +- [ ] **Step 5: Final commit with any rubocop/test fixes** + +```bash +git add -u +git commit -m "chore: rubocop fixes for Phase 7.1" +``` + +--- + +## Deferred from Design Spec + +These items from the design spec are intentionally deferred to avoid scope creep: + +- **Table existence validation** (`validate_pinned_tables` config) — boot/migrate ordering makes this fragile. Current behavior (no validation) matches v3. Add in a follow-up if real apps hit confusing errors. +- **`has_many :through` pinned model test** — cross-DB joins are engine-specific and complex. Document behavior rather than test exhaustively. +- **`connects_to` coexistence test** — pre-existing issue, not introduced by Phase 7.1. Verify manually if needed. diff --git a/docs/plans/apartment-v4/phase-8-documentation.md b/docs/plans/apartment-v4/phase-8-documentation.md new file mode 100644 index 00000000..fbeb3aa7 --- /dev/null +++ b/docs/plans/apartment-v4/phase-8-documentation.md @@ -0,0 +1,627 @@ +# Phase 8: Documentation & Upgrade Guide — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make v4 shippable by rewriting user-facing docs, providing an upgrade guide for external users, and updating maintainer docs for dual-release support. + +**Architecture:** Seven deliverables, all documentation/markdown except one workflow YAML change. No application code changes. Tasks are ordered so later tasks can reference earlier files (e.g., upgrade guide links to README, README links to upgrade guide). + +**Tech Stack:** Markdown, Ruby comments (install template), GitHub Actions YAML + +**Spec:** `docs/designs/v4-phase8-documentation.md` + +**Packaging note:** The gemspec (`ros-apartment.gemspec`) packages `README.md` + `lib/` only. Docs in `docs/` are GitHub-only and don't ship in the gem. The `docs/history/` files are for GitHub readers, not gem consumers. + +**Changelog strategy:** v4 uses GitHub Releases as the changelog (no `CHANGELOG.md` at root). The v3 changelog is archived at `docs/history/CHANGELOG-v3.md`. + +--- + +### Task 1: Create Feature Branch + +**Files:** +- None (git operation only) + +- [ ] **Step 1: Create and checkout feature branch** + +```bash +git checkout -b phase-8-documentation development +``` + +- [ ] **Step 2: Verify clean state** + +```bash +git status +``` + +Expected: `On branch phase-8-documentation`, nothing to commit. + +--- + +### Task 2: Move Legacy Files to `docs/history/` + +**Files:** +- Move: `legacy_CHANGELOG.md` → `docs/history/CHANGELOG-v3.md` +- Create: `docs/history/README-v3.md` (copy of current README.md with header) + +**Pre-check:** The current README on `development` is still the v3-era doc (references `config.excluded_models`, `config.tenant_names`). If the README has already been partially migrated to v4, snapshot from the last v3 release tag instead: `git show v3.4.1:README.md > docs/history/README-v3.md`. + +- [ ] **Step 1: Create `docs/history/` directory** + +```bash +mkdir -p docs/history +``` + +- [ ] **Step 2: Move `legacy_CHANGELOG.md`** + +```bash +git mv legacy_CHANGELOG.md docs/history/CHANGELOG-v3.md +``` + +- [ ] **Step 3: Add header to `docs/history/CHANGELOG-v3.md`** + +Prepend to the top of the file (before existing content): + +```markdown +> **Note:** This changelog covers Apartment v3.x and earlier. For v4+, see [GitHub Releases](https://github.com/rails-on-services/apartment/releases). + +``` + +- [ ] **Step 4: Copy current README to `docs/history/README-v3.md`** + +```bash +cp README.md docs/history/README-v3.md +``` + +- [ ] **Step 5: Add header to `docs/history/README-v3.md`** + +Prepend to the top of the file (before the `# Apartment` heading): + +```markdown +> **Note:** This documents Apartment v3.x. For v4, see [README.md](../../README.md). + +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/history/ +git commit -m "Move legacy docs to docs/history/ + +Moves legacy_CHANGELOG.md to docs/history/CHANGELOG-v3.md and snapshots +the current v3 README as docs/history/README-v3.md. Both get headers +pointing readers to the current versions." +``` + +--- + +### Task 3: Rewrite `README.md` for v4 + +**Files:** +- Modify: `README.md` (full rewrite) + +**Reference files to read before writing:** +- `lib/apartment/config.rb` — all config options and defaults +- `lib/apartment/tenant.rb` — public API methods +- `lib/apartment/concerns/model.rb` — pin_tenant API +- `lib/apartment/railtie.rb` — elevator auto-insertion +- `lib/apartment/elevators/` — available elevator classes +- `docs/designs/v4-phase8-documentation.md` — spec section 2 for structure + +- [ ] **Step 1: Write the new README.md** + +Replace the entire contents of `README.md` with the v4 rewrite. The structure must follow the spec (section 2) exactly. Key sections and their content: + +**Header + badges**: Keep existing badge URLs. Update one-liner and code example: + +```markdown +# Apartment + +[![Gem Version](https://badge.fury.io/rb/ros-apartment.svg)](https://badge.fury.io/rb/ros-apartment) +[![CI](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml/badge.svg)](https://github.com/rails-on-services/apartment/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/rails-on-services/apartment/graph/badge.svg?token=Q4I5QL78SA)](https://codecov.io/gh/rails-on-services/apartment) + +*Database-level multitenancy for Rails and ActiveRecord* + +Apartment isolates tenant data at the **database level** — using PostgreSQL schemas or separate databases — so that tenant data separation is enforced by the database engine, not application code. + +```ruby +Apartment::Tenant.switch('acme') do + User.all # only returns users in the 'acme' schema/database +end +``` +``` + +**When to Use Apartment**: Keep the existing comparison table verbatim (it's good). + +**About ros-apartment**: Keep existing text, add mention of v4. + +**Installation**: Update requirements to Ruby 3.3+, Rails 7.2+, PG 14+/MySQL 8.4+/SQLite3. Keep Gemfile + generator commands. + +**Quick Start**: Show the v4 configure block with `tenant_strategy`, `tenants_provider`, `default_tenant`. State the invariant: "Tenant context is block-scoped; prefer `Tenant.switch { ... }` in app code." Show `create`/`switch`/`drop` examples. Show `Apartment::Model` + `pin_tenant` for global models. + +```ruby +Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { Customer.pluck(:subdomain) } + config.default_tenant = 'public' +end +``` + +```ruby +# Global models — pinned to the default tenant +class Company < ApplicationRecord + include Apartment::Model + pin_tenant +end +``` + +**Configuration Reference**: Subsections for Required Options, Pool Settings, Elevator, Migrations, RBAC, PostgreSQL, MySQL. Use the config options from `lib/apartment/config.rb`. For PostgreSQL, keep the shared extensions rake example from the current README. + +**Elevators**: Keep current elevator list and examples. Note that Railtie auto-inserts middleware — no manual `config.middleware.use` needed. Update elevator configuration to use `config.elevator = :subdomain` style. Keep custom elevator example but update base class if needed. + +**Pinned Models (Global Tables)**: New section. Show `include Apartment::Model` + `pin_tenant`. Explain why (declarative, Zeitwerk-safe, composable with `connected_to`). Note `has_many :through` requirement (no HABTM). Note `connected_to(role: :reading)` compatibility. Cross-reference Known Limitations for the `connects_to` separate database edge case. + +**Callbacks**: Keep current content (same API). + +**Migrations**: Keep content, ensure rake task names are `apartment:create`, `apartment:drop`, `apartment:migrate`, `apartment:seed`, `apartment:rollback`. Keep parallel migrations section. + +**Known Limitations**: `connects_to` with separate databases section per spec. + +**Background Workers**: Keep content, update examples to block-based `switch`. + +**Rails Console**: Keep current content. + +**Troubleshooting**: Keep relevant items, remove v3-only items. Keep `APARTMENT_DISABLE_INIT` and `tenant_presence_check` tips. + +**Upgrading from v3**: One-paragraph pointer to `docs/upgrading-to-v4.md`. + +**Contributing**: Keep, verify test commands match current reality. + +**License**: Keep. + +Content NOT included (per spec): +- `config.excluded_models` (upgrade guide only) +- `config.tenant_names` (removed) +- `config.use_schemas` / `config.use_sql` (removed) +- `switch!` as recommended pattern (mention exists for console, discourage in app code) +- Multi-server setup (not ported to v4) + +- [ ] **Step 2: Review the README for internal consistency** + +Read through the written README. Check: +- All config option names match `lib/apartment/config.rb` +- All method names match `lib/apartment/tenant.rb` +- All elevator class names match `lib/apartment/elevators/` +- No v3 API references leaked in +- Cross-references (to upgrade guide, to Known Limitations) are correct + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "Rewrite README for v4 API + +Restructured around v4 mental model: tenant_strategy, tenants_provider, +Apartment::Model + pin_tenant, pool-per-tenant, Railtie auto-wiring. +Removed v3-only config (excluded_models, tenant_names, use_schemas). +Added Pinned Models and Known Limitations sections. v3 README preserved +in docs/history/README-v3.md." +``` + +--- + +### Task 4: Write Upgrade Guide + +**Files:** +- Create: `docs/upgrading-to-v4.md` + +**Reference files to read before writing:** +- `docs/designs/v4-phase8-documentation.md` — spec section 3 for structure +- PR #327's upgrade doc: `git show pr-327:docs/4.0-Upgrade.md` — port useful content, correct inaccuracies +- `lib/apartment/config.rb` — verify config option names +- `lib/apartment/tenant.rb` — verify API method names + +- [ ] **Step 1: Write `docs/upgrading-to-v4.md`** + +Follow the spec structure exactly. Full content for each section: + +**Requirements**: Ruby 3.3+, Rails 7.2+, PostgreSQL 14+, MySQL 8.4+, SQLite3. + +**What Changed and Why**: 3-4 sentences. Pool-per-tenant replaces thread-local switching. Fiber-safe via `CurrentAttributes`. Config is immutable after boot. Declarative model pinning replaces config-list approach. + +**Breaking Changes**: Five subsections (Configuration, Tenant API, Models, Middleware, Connection Model) per spec. Use before/after code examples for each change. Key accuracy points: +- `Apartment::Tenant.current` is unchanged (same in v3 and v4) — do NOT list as breaking +- `excluded_models` is deprecated (still works in v4, removed in v5) — not "removed" +- `process_excluded_models` is deprecated — not "removed" + +Example before/after for Configuration: + +```ruby +# v3 +Apartment.configure do |config| + config.excluded_models = %w[User Company] + config.tenant_names = -> { Customer.pluck(:subdomain) } + config.use_schemas = true +end + +# v4 +Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { Customer.pluck(:subdomain) } + config.default_tenant = 'public' +end +# In each global model: +# include Apartment::Model +# pin_tenant +``` + +**Migration Steps**: Seven steps per spec. Each step has concrete find/replace patterns or before/after code. Step 3 accuracy: `switch!(t) ... ensure reset` → `switch(t) { ... }` and `switch_to(t) ... ensure reset!` → `switch(t) { ... }`. Note that `Apartment::Tenant.current` is unchanged. + +**connects_to Compatibility**: Expanded version of README's Known Limitations. Common case works (ApplicationRecord with roles). Edge case: model with `connects_to` to separate database needs `pin_tenant`. `connected_to(role: :reading)` works correctly with pinned models. + +**Troubleshooting**: Port from PR #327 with corrections: +- "No connection defined for tenant" → check `tenants_provider` returns valid names +- Connection pool sizing → `tenant_pool_size`, `max_total_connections` +- Thread safety → always use block-scoped `switch` +- Do NOT include: emoji headers, inflated benchmarks, inaccurate API claims from PR #327 + +- [ ] **Step 2: Cross-check against PR #327 for missed useful content** + +```bash +git show pr-327:docs/4.0-Upgrade.md 2>/dev/null || echo "pr-327 branch not available locally" +``` + +If the branch is not available locally, fetch it or reference the PR on GitHub: +`https://github.com/rails-on-services/apartment/pull/327/files` + +Scan for any useful troubleshooting items or migration steps not already covered. Add if relevant. + +- [ ] **Step 3: Verify all method and config names against source** + +Grep for every config option and method name mentioned in the upgrade guide to confirm they exist in the codebase: + +```bash +grep -n 'tenants_provider\|tenant_strategy\|excluded_models\|pin_tenant\|process_pinned_models' lib/apartment/config.rb lib/apartment/tenant.rb lib/apartment/concerns/model.rb +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/upgrading-to-v4.md +git commit -m "Add v4 upgrade guide for external users + +Self-sufficient guide covering breaking changes, step-by-step migration, +connects_to compatibility, and troubleshooting. Ported useful content +from PR #327 with corrected requirements and API references." +``` + +--- + +### Task 5: Update Install Template + +**Files:** +- Modify: `lib/generators/apartment/install/templates/apartment.rb:19-21` + +- [ ] **Step 1: Update the excluded_models comment block** + +In `lib/generators/apartment/install/templates/apartment.rb`, replace: + +```ruby + # Models that live in the shared/default schema (not per-tenant). + # config.excluded_models = %w[Account] +``` + +With: + +```ruby + # Models that live in the shared/default schema (not per-tenant). + # The recommended approach is to declare this in the model itself: + # + # class Account < ApplicationRecord + # include Apartment::Model + # pin_tenant + # end + # + # Legacy alternative (deprecated in v4, removed in v5): + # config.excluded_models = %w[Account] +``` + +- [ ] **Step 2: Verify the template still parses as valid Ruby** + +```bash +ruby -c lib/generators/apartment/install/templates/apartment.rb +``` + +Expected: `Syntax OK` + +- [ ] **Step 3: Commit** + +```bash +git add lib/generators/apartment/install/templates/apartment.rb +git commit -m "Update install template to recommend pin_tenant + +Adds commented example showing Apartment::Model + pin_tenant as the +recommended v4 approach. Keeps excluded_models as deprecated fallback." +``` + +--- + +### Task 6: Update `RELEASING.md` for Dual Release + +**Files:** +- Modify: `RELEASING.md` + +- [ ] **Step 1: Add dual release section** + +After the existing "Troubleshooting" section (end of file, line 107), append: + +```markdown + +## Dual Release (v4 + v3 maintenance) + +While v3 is still supported, maintenance releases (bug fixes, security patches) are cut from the `v3-stable` branch. + +### v4 releases + +Same as current process: `development` → `main` → publish. + +### v3 maintenance releases + +The `gem-publish.yml` workflow triggers on push to `main` and on `v3.*` tags (see below). For v3 releases: + +1. Create or checkout the `v3-stable` branch (branched from the last v3 release tag) +2. Cherry-pick or apply fixes +3. Bump version in `lib/apartment/version.rb` (e.g., `3.4.2`) +4. Push `v3-stable` to origin +5. Tag and push: `git tag v3.4.2 && git push origin v3.4.2` + - The tag push triggers `gem-publish.yml`, which checks out the tagged commit + - Do **not** merge `v3-stable` into `main` — `main` contains v4 code +6. Create a GitHub Release from the `v3.4.2` tag, noting it as a maintenance release + +GitHub Actions `*` matches any character sequence including dots, so the `v3.*` pattern matches tags like `v3.4.2`. Only maintainers should push v3 tags; the `production` environment protection on the workflow provides an additional safeguard. + +### Version coordination + +- v4 uses `4.x.y` version numbers +- v3 maintenance uses `3.4.x` version numbers +- Both publish to the same `ros-apartment` gem on RubyGems +- RubyGems resolves via version constraints in user Gemfiles + +### End of v3 support + +When v3 maintenance ends, delete the `v3-stable` branch and remove this section. +``` + +- [ ] **Step 2: Commit** + +```bash +git add RELEASING.md +git commit -m "Add dual-release process for v3 maintenance + +Documents v3-stable branch workflow: cherry-pick fixes, tag from branch, +publish via tag trigger. v3-stable never merges into main." +``` + +--- + +### Task 7: Add Tag Trigger to `gem-publish.yml` + +**Files:** +- Modify: `.github/workflows/gem-publish.yml:3-5` + +- [ ] **Step 1: Add tags trigger** + +In `.github/workflows/gem-publish.yml`, replace: + +```yaml +on: + push: + branches: [ 'main' ] +``` + +With: + +```yaml +on: + push: + branches: [ 'main' ] + tags: [ 'v3.*' ] +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/gem-publish.yml +git commit -m "Add v3 tag trigger to gem-publish workflow + +Allows v3 maintenance releases to be published by pushing a v3.* tag +from the v3-stable branch, without merging into main." +``` + +--- + +### Task 8: Update CLAUDE.md Files + +**Files:** +- Modify: `CLAUDE.md` (root) +- Modify: `lib/apartment/CLAUDE.md` +- Modify: `spec/CLAUDE.md` + +- [ ] **Step 1: Update root `CLAUDE.md` — Core Concepts** + +In `CLAUDE.md`, in the "Core Concepts" section (around line 74), replace: + +```markdown +- **Excluded models**: Shared tables (User, Company) pinned to default tenant. Use `has_many :through`, not HABTM. +``` + +With: + +```markdown +- **Pinned models**: Global tables declared with `Apartment::Model` + `pin_tenant`. Bypasses tenant routing. Use `has_many :through`, not HABTM. Replaces `excluded_models` (deprecated in v4). +``` + +- [ ] **Step 2: Update root `CLAUDE.md` — Testing spec count** + +First, get the current count: + +```bash +bundle exec rspec spec/unit/ --dry-run 2>&1 | grep "examples" +``` + +In the "Testing" section (around line 89), replace the count in: + +```markdown +bundle exec rspec spec/unit/ # v4 unit tests (231 specs) +``` + +With the actual count from the dry-run (576 as of this writing, but use the live value). + +- [ ] **Step 3: Update root `CLAUDE.md` — Gotchas** + +After the last gotcha bullet (the `v4 Railtie` bullet, around line 111), add: + +```markdown +- **`connects_to` edge case**: Models (or abstract base classes) that use `connects_to` to point at a separate database need `pin_tenant` to prevent Apartment from creating tenant pools for them. The common pattern of `ApplicationRecord` using `connects_to` with multiple roles (writing/reading) on the same database works correctly without any special handling. +``` + +- [ ] **Step 4: Update `lib/apartment/CLAUDE.md` — Directory structure** + +In `lib/apartment/CLAUDE.md`, in the directory structure tree (after the `configs/` entry, around line 18), add: + +``` +├── concerns/ # ActiveRecord concerns for tenant-aware models +│ └── model.rb # Apartment::Model concern: pin_tenant, apartment_pinned? +``` + +- [ ] **Step 5: Update `lib/apartment/CLAUDE.md` — File description** + +After the "### Concrete Adapters (Phase 2.2)" section (around line 71), add a new section: + +```markdown +### concerns/model.rb — Model Pinning Concern + +`Apartment::Model` provides `pin_tenant` (class method) to declare a model as pinned to the default tenant. Registered models bypass the `ConnectionHandling` patch. Zeitwerk-safe: works whether called before or after `activate!`. `apartment_pinned?` checks the class and its superclass chain. +``` + +- [ ] **Step 6: Update `spec/CLAUDE.md` — Unit test file listing** + +In `spec/CLAUDE.md`, in the "Adapter Tests (spec/unit/)" section's file listing (after the sqlite3 adapter spec entry, around line 40), add: + +```markdown +- `concerns/model_spec.rb` - Apartment::Model concern (pin_tenant, apartment_pinned?, inheritance) +- `patches/connection_handling_spec.rb` - ConnectionHandling patch (tenant pool routing, pinned model bypass, role keying) +``` + +- [ ] **Step 7: Update `spec/CLAUDE.md` — Spec count in header** + +In `spec/CLAUDE.md`, in the opening note (line 1), replace `375+` with the live unit spec count from `bundle exec rspec spec/unit/ --dry-run`. + +- [ ] **Step 8: Commit** + +```bash +git add CLAUDE.md lib/apartment/CLAUDE.md spec/CLAUDE.md +git commit -m "Update CLAUDE.md files for Phase 7.1 additions + +Adds concerns/model.rb docs, updates spec counts (576 unit specs), +replaces excluded_models with pin_tenant in Core Concepts, adds +connects_to gotcha, notes new spec files." +``` + +--- + +### Task 9: Run Rubocop and Final Verification + +**Files:** +- None (verification only) + +- [ ] **Step 1: Run rubocop on changed Ruby files** + +```bash +bundle exec rubocop lib/generators/apartment/install/templates/apartment.rb +``` + +Expected: no offenses. (The YAML workflow change is not linted by RuboCop; rely on CI's workflow validation or `actionlint` if available.) + +- [ ] **Step 1a: Run generator specs to confirm template change is compatible** + +```bash +bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/generator/ --format progress +``` + +Expected: all pass. The existing specs check for `tenant_strategy`, `tenants_provider`, and absence of v3 references — our comment-only change should not affect them. + +- [ ] **Step 2: Verify all markdown links resolve** + +Check that cross-references between docs work: + +```bash +# Verify files referenced in README exist +test -f docs/upgrading-to-v4.md && echo "upgrade guide: OK" || echo "MISSING" +test -f docs/history/README-v3.md && echo "legacy readme: OK" || echo "MISSING" +test -f docs/history/CHANGELOG-v3.md && echo "legacy changelog: OK" || echo "MISSING" + +# Verify files referenced in CLAUDE.md exist +test -f lib/apartment/concerns/model.rb && echo "model concern: OK" || echo "MISSING" +test -f spec/unit/concerns/model_spec.rb && echo "model spec: OK" || echo "MISSING" +test -f spec/unit/patches/connection_handling_spec.rb && echo "connection handling spec: OK" || echo "MISSING" +``` + +Expected: all OK. + +- [ ] **Step 3: Verify unit tests still pass** + +```bash +bundle exec rspec spec/unit/ --format progress +``` + +Expected: all pass, 0 failures (documentation changes should not affect tests). + +--- + +### Task 10: Push and Create PR + +**Files:** +- None (git operations only) + +- [ ] **Step 1: Push feature branch** + +```bash +git push -u origin phase-8-documentation +``` + +- [ ] **Step 2: Create pull request** + +```bash +gh pr create --base development --title "Phase 8: Documentation & Upgrade Guide" --body "$(cat <<'EOF' +## Summary + +- Moves legacy docs to `docs/history/` (README-v3.md, CHANGELOG-v3.md) +- Rewrites README.md for v4 API (tenant_strategy, tenants_provider, pin_tenant, pool-per-tenant) +- Adds `docs/upgrading-to-v4.md` — self-sufficient upgrade guide for external users +- Updates install template to recommend `Apartment::Model` + `pin_tenant` +- Adds dual-release process to RELEASING.md (v4 from main, v3 from tags) +- Adds `v3.*` tag trigger to gem-publish workflow +- Updates all CLAUDE.md files (root, lib/apartment/, spec/) + +## Design spec + +`docs/designs/v4-phase8-documentation.md` + +## Test plan + +- [ ] Rubocop passes on changed files +- [ ] All markdown cross-references resolve to existing files +- [ ] Unit tests pass (576 specs, no database required) +- [ ] Install template is valid Ruby (`ruby -c`) +- [ ] Review README Quick Start examples against actual v4 config API +- [ ] Review upgrade guide breaking changes against actual v4 behavior + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 3: Record PR URL** + +Note the PR URL for the user. diff --git a/docs/plans/v4-elevators.md b/docs/plans/v4-elevators.md new file mode 100644 index 00000000..137d17bd --- /dev/null +++ b/docs/plans/v4-elevators.md @@ -0,0 +1,1022 @@ +# Phase 3: Elevators Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite all elevator middleware for v4, add new Header elevator, wire keyword-arg config through the Railtie. + +**Architecture:** Elevators are Rack middleware that detect tenant from HTTP requests and call `Apartment::Tenant.switch(tenant) { @app.call(env) }`. Constructor-only configuration via `elevator_options` keyword args; no class-level mutable state. Generic base class; six subclasses (Subdomain, FirstSubdomain, Domain, Host, HostHash, Header). + +**Tech Stack:** Ruby, Rack, PublicSuffix gem (for Subdomain/FirstSubdomain), RSpec + +**Spec:** `docs/designs/v4-elevators.md` + +--- + +## File Map + +### Create +| File | Responsibility | +|------|---------------| +| `lib/apartment/elevators/header.rb` | Header-based tenant resolution (new elevator) | +| `spec/unit/elevators/generic_spec.rb` | Generic base class tests | +| `spec/unit/elevators/subdomain_spec.rb` | Subdomain elevator tests | +| `spec/unit/elevators/first_subdomain_spec.rb` | FirstSubdomain elevator tests | +| `spec/unit/elevators/domain_spec.rb` | Domain elevator tests | +| `spec/unit/elevators/host_spec.rb` | Host elevator tests | +| `spec/unit/elevators/host_hash_spec.rb` | HostHash elevator tests | +| `spec/unit/elevators/header_spec.rb` | Header elevator tests | + +### Modify +| File | Change | +|------|--------| +| `lib/apartment/elevators/generic.rb` | Add `**_options` splat to constructor, `NotImplementedError` in `parse_tenant_name` | +| `lib/apartment/elevators/subdomain.rb` | Constructor keyword args, remove class-level setters | +| `lib/apartment/elevators/first_subdomain.rb` | Fix double-`super` call | +| `lib/apartment/elevators/host.rb` | Constructor keyword args, remove class-level setters | +| `lib/apartment/elevators/host_hash.rb` | Constructor keyword args (`hash:`), drop positional `processor` | +| `lib/apartment/elevators/domain.rb` | No code changes (inherits Generic's new constructor); verify tests pass | +| `lib/apartment/railtie.rb` | `**opts` (not `*values`), `resolve_elevator_class` handles classes, Header trust warning | +| `lib/apartment.rb` | Remove Zeitwerk ignore for `elevators/` directory | + +--- + +## Task 1: Remove Zeitwerk ignore + update Generic base class + +**Files:** +- Modify: `lib/apartment.rb:18-19` +- Modify: `lib/apartment/elevators/generic.rb` +- Create: `spec/unit/elevators/generic_spec.rb` + +- [ ] **Step 1: Write failing tests for Generic** + +Create `spec/unit/elevators/generic_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/generic' + +RSpec.describe(Apartment::Elevators::Generic) do + let(:inner_app) { ->(env) { [200, { 'Content-Type' => 'text/plain' }, [env['apartment.tenant'] || 'default']] } } + + describe '#call' do + it 'switches tenant when processor returns a tenant name' do + elevator = described_class.new(inner_app, ->(_req) { 'acme' }) + + expect(Apartment::Tenant).to(receive(:switch).with('acme').and_yield) + + elevator.call(Rack::MockRequest.env_for('http://example.com')) + end + + it 'does not switch when processor returns nil' do + elevator = described_class.new(inner_app, ->(_req) { nil }) + + expect(Apartment::Tenant).not_to(receive(:switch)) + + elevator.call(Rack::MockRequest.env_for('http://example.com')) + end + + it 'calls the inner app' do + elevator = described_class.new(inner_app, ->(_req) { nil }) + + status, = elevator.call(Rack::MockRequest.env_for('http://example.com')) + expect(status).to(eq(200)) + end + + it 'uses parse_tenant_name when no processor provided' do + subclass = Class.new(described_class) do + def parse_tenant_name(_request) + 'from_subclass' + end + end + + elevator = subclass.new(inner_app) + expect(Apartment::Tenant).to(receive(:switch).with('from_subclass').and_yield) + + elevator.call(Rack::MockRequest.env_for('http://example.com')) + end + + it 'absorbs keyword args without error' do + expect { described_class.new(inner_app, nil, some_option: 'value') }.not_to(raise_error) + end + end + + describe '#parse_tenant_name' do + it 'raises NotImplementedError by default' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('http://example.com')) + + expect { elevator.parse_tenant_name(request) } + .to(raise_error(NotImplementedError, /parse_tenant_name must be implemented/)) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/elevators/generic_spec.rb` +Expected: Failures (file doesn't match expected interface yet) + +- [ ] **Step 3: Update Generic implementation** + +Replace `lib/apartment/elevators/generic.rb`: + +```ruby +# frozen_string_literal: true + +require 'rack/request' +require 'apartment/tenant' + +module Apartment + module Elevators + # Base elevator — Rack middleware that detects tenant from request and switches context. + # Subclasses override parse_tenant_name(request). Custom logic via Proc in constructor. + class Generic + def initialize(app, processor = nil, **_options) + @app = app + @processor = processor || method(:parse_tenant_name) + end + + def call(env) + request = Rack::Request.new(env) + database = @processor.call(request) + + if database + Apartment::Tenant.switch(database) { @app.call(env) } + else + @app.call(env) + end + end + + def parse_tenant_name(_request) + raise(NotImplementedError, "#{self.class}#parse_tenant_name must be implemented") + end + end + end +end +``` + +- [ ] **Step 4: Remove Zeitwerk ignore for elevators** + +In `lib/apartment.rb`, remove lines 18-19: +```ruby +# v3 elevators — will be replaced in Phase 3. +loader.ignore("#{__dir__}/apartment/elevators") +``` + +- [ ] **Step 5: Run Generic tests to verify they pass** + +Run: `bundle exec rspec spec/unit/elevators/generic_spec.rb` +Expected: All pass + +- [ ] **Step 6: Run full unit suite to verify Zeitwerk ignore removal is safe** + +Run: `bundle exec rspec spec/unit/` +Expected: All pass (other elevator files are valid Ruby; Zeitwerk can load them) + +- [ ] **Step 7: Commit** + +```bash +git add lib/apartment.rb lib/apartment/elevators/generic.rb spec/unit/elevators/generic_spec.rb +git commit -m "v4 Generic elevator: keyword args splat, NotImplementedError default" +``` + +--- + +## Task 2: Subdomain elevator + +**Files:** +- Modify: `lib/apartment/elevators/subdomain.rb` +- Create: `spec/unit/elevators/subdomain_spec.rb` + +- [ ] **Step 1: Write failing tests for Subdomain** + +Create `spec/unit/elevators/subdomain_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/subdomain' + +RSpec.describe(Apartment::Elevators::Subdomain) do + let(:inner_app) { ->(env) { [200, {}, ['ok']] } } + + def env_for(host) + Rack::MockRequest.env_for("http://#{host}/") + end + + def request_for(host) + Rack::Request.new(env_for(host)) + end + + describe '#parse_tenant_name' do + it 'extracts subdomain from host' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme')) + end + + it 'returns nil for excluded subdomains' do + elevator = described_class.new(inner_app, excluded_subdomains: %w[www api]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(be_nil) + end + + it 'returns nil for bare domain (no subdomain)' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('example.com'))).to(be_nil) + end + + it 'returns nil for IP addresses' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('127.0.0.1'))).to(be_nil) + end + + it 'handles international TLDs correctly' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.co.uk'))).to(eq('acme')) + end + + it 'coerces excluded_subdomains to strings' do + elevator = described_class.new(inner_app, excluded_subdomains: [:www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(be_nil) + end + + it 'freezes excluded_subdomains' do + elevator = described_class.new(inner_app, excluded_subdomains: %w[www]) + expect(elevator.instance_variable_get(:@excluded_subdomains)).to(be_frozen) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/elevators/subdomain_spec.rb` +Expected: Failures (constructor doesn't accept keyword args) + +- [ ] **Step 3: Update Subdomain implementation** + +Replace `lib/apartment/elevators/subdomain.rb`: + +```ruby +# frozen_string_literal: true + +require 'apartment/elevators/generic' +require 'public_suffix' + +module Apartment + module Elevators + # Tenant from subdomain. Uses PublicSuffix for international TLD handling. + class Subdomain < Generic + def initialize(app, excluded_subdomains: [], **_options) + super(app) + @excluded_subdomains = Array(excluded_subdomains).map(&:to_s).freeze + end + + def parse_tenant_name(request) + request_subdomain = subdomain(request.host) + + return nil if request_subdomain.blank? + return nil if @excluded_subdomains.include?(request_subdomain) + + request_subdomain + end + + protected + + def subdomain(host) + subdomains(host).first + end + + def subdomains(host) + host_valid?(host) ? parse_host(host) : [] + end + + def host_valid?(host) + !ip_host?(host) && domain_valid?(host) + end + + def ip_host?(host) + !/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil? + end + + def domain_valid?(host) + PublicSuffix.valid?(host, ignore_private: true) + end + + def parse_host(host) + (PublicSuffix.parse(host, ignore_private: true).trd || '').split('.') + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/elevators/subdomain_spec.rb` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/elevators/subdomain.rb spec/unit/elevators/subdomain_spec.rb +git commit -m "v4 Subdomain elevator: constructor keyword args, remove class-level setters" +``` + +--- + +## Task 3: FirstSubdomain elevator + +**Note:** With the current Subdomain implementation (`subdomain` returns `subdomains(host).first`), FirstSubdomain is functionally equivalent to Subdomain for most inputs. The `.split('.').first` in FirstSubdomain is a no-op because `subdomain` already returns the first segment. We keep the class for backward compatibility (existing subclasses like DynamicElevator inherit from it), and the double-super fix is still worth doing for correctness. + +**Files:** +- Modify: `lib/apartment/elevators/first_subdomain.rb` +- Create: `spec/unit/elevators/first_subdomain_spec.rb` + +- [ ] **Step 1: Write failing tests for FirstSubdomain** + +Create `spec/unit/elevators/first_subdomain_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/first_subdomain' + +RSpec.describe(Apartment::Elevators::FirstSubdomain) do + let(:inner_app) { ->(env) { [200, {}, ['ok']] } } + + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end + + describe '#parse_tenant_name' do + it 'extracts the first subdomain segment' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.staging.example.com'))).to(eq('acme')) + end + + it 'works with a single subdomain' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme')) + end + + it 'returns nil when no subdomain' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('example.com'))).to(be_nil) + end + + it 'respects excluded_subdomains from Subdomain' do + elevator = described_class.new(inner_app, excluded_subdomains: %w[www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(be_nil) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/elevators/first_subdomain_spec.rb` +Expected: Failures (double-super issue or constructor mismatch) + +- [ ] **Step 3: Update FirstSubdomain implementation** + +Replace `lib/apartment/elevators/first_subdomain.rb`: + +```ruby +# frozen_string_literal: true + +require 'apartment/elevators/subdomain' + +module Apartment + module Elevators + # Tenant from the first segment of nested subdomains. + # acme.staging.example.com -> acme + class FirstSubdomain < Subdomain + def parse_tenant_name(request) + tenant = super + return nil if tenant.nil? + + tenant.split('.').first + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/elevators/first_subdomain_spec.rb` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/elevators/first_subdomain.rb spec/unit/elevators/first_subdomain_spec.rb +git commit -m "v4 FirstSubdomain elevator: fix double-super call" +``` + +--- + +## Task 4: Domain elevator + +**Files:** +- Modify: `lib/apartment/elevators/domain.rb` (no changes needed, but verify) +- Create: `spec/unit/elevators/domain_spec.rb` + +- [ ] **Step 1: Write tests for Domain** + +Create `spec/unit/elevators/domain_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/domain' + +RSpec.describe(Apartment::Elevators::Domain) do + let(:inner_app) { ->(env) { [200, {}, ['ok']] } } + + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end + + describe '#parse_tenant_name' do + it 'extracts domain name from simple host' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('example.com'))).to(eq('example')) + end + + it 'strips www prefix' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(eq('example')) + end + + it 'extracts first non-www segment with subdomains' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('a.example.bc.ca'))).to(eq('a')) + end + + it 'strips www even with complex TLD' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('www.example.bc.ca'))).to(eq('example')) + end + + it 'returns nil for blank host' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('/')) + allow(request).to(receive(:host).and_return('')) + expect(elevator.parse_tenant_name(request)).to(be_nil) + end + end +end +``` + +- [ ] **Step 2: Run tests** + +Run: `bundle exec rspec spec/unit/elevators/domain_spec.rb` +Expected: All pass (Domain implementation is unchanged from v3) + +- [ ] **Step 3: Commit** + +```bash +git add lib/apartment/elevators/domain.rb spec/unit/elevators/domain_spec.rb +git commit -m "v4 Domain elevator: add unit tests" +``` + +--- + +## Task 5: Host elevator + +**Files:** +- Modify: `lib/apartment/elevators/host.rb` +- Create: `spec/unit/elevators/host_spec.rb` + +- [ ] **Step 1: Write failing tests for Host** + +Create `spec/unit/elevators/host_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/host' + +RSpec.describe(Apartment::Elevators::Host) do + let(:inner_app) { ->(env) { [200, {}, ['ok']] } } + + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end + + describe '#parse_tenant_name' do + it 'returns the full hostname' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme.example.com')) + end + + it 'strips ignored first subdomains' do + elevator = described_class.new(inner_app, ignored_first_subdomains: %w[www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(eq('example.com')) + end + + it 'does not strip non-ignored subdomains' do + elevator = described_class.new(inner_app, ignored_first_subdomains: %w[www]) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme.example.com')) + end + + it 'returns nil for blank host' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('/')) + allow(request).to(receive(:host).and_return('')) + expect(elevator.parse_tenant_name(request)).to(be_nil) + end + + it 'coerces ignored_first_subdomains to strings' do + elevator = described_class.new(inner_app, ignored_first_subdomains: [:www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(eq('example.com')) + end + + it 'freezes ignored_first_subdomains' do + elevator = described_class.new(inner_app, ignored_first_subdomains: %w[www]) + expect(elevator.instance_variable_get(:@ignored_first_subdomains)).to(be_frozen) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/elevators/host_spec.rb` +Expected: Failures (constructor doesn't accept keyword args) + +- [ ] **Step 3: Update Host implementation** + +Replace `lib/apartment/elevators/host.rb`: + +```ruby +# frozen_string_literal: true + +require 'apartment/elevators/generic' + +module Apartment + module Elevators + # Tenant from full hostname. Optionally strips ignored first subdomains (e.g., www). + class Host < Generic + def initialize(app, ignored_first_subdomains: [], **_options) + super(app) + @ignored_first_subdomains = Array(ignored_first_subdomains).map(&:to_s).freeze + end + + def parse_tenant_name(request) + return nil if request.host.blank? + + parts = request.host.split('.') + @ignored_first_subdomains.include?(parts[0]) ? parts.drop(1).join('.') : request.host + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/elevators/host_spec.rb` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/elevators/host.rb spec/unit/elevators/host_spec.rb +git commit -m "v4 Host elevator: constructor keyword args, remove class-level setters" +``` + +--- + +## Task 6: HostHash elevator + +**Files:** +- Modify: `lib/apartment/elevators/host_hash.rb` +- Create: `spec/unit/elevators/host_hash_spec.rb` + +- [ ] **Step 1: Write failing tests for HostHash** + +Create `spec/unit/elevators/host_hash_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/host_hash' + +RSpec.describe(Apartment::Elevators::HostHash) do + let(:inner_app) { ->(env) { [200, {}, ['ok']] } } + let(:mapping) { { 'acme.com' => 'acme', 'widgets.io' => 'widgets' } } + + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end + + describe '#parse_tenant_name' do + it 'returns the mapped tenant for a known host' do + elevator = described_class.new(inner_app, hash: mapping) + expect(elevator.parse_tenant_name(request_for('acme.com'))).to(eq('acme')) + end + + it 'raises TenantNotFound for an unknown host' do + elevator = described_class.new(inner_app, hash: mapping) + expect { elevator.parse_tenant_name(request_for('unknown.com')) } + .to(raise_error(Apartment::TenantNotFound, /unknown\.com/)) + end + + it 'sets the tenant attribute on TenantNotFound' do + elevator = described_class.new(inner_app, hash: mapping) + expect { elevator.parse_tenant_name(request_for('unknown.com')) } + .to(raise_error { |e| expect(e.tenant).to(eq('unknown.com')) }) + end + + it 'freezes the hash' do + elevator = described_class.new(inner_app, hash: mapping) + expect(elevator.instance_variable_get(:@hash)).to(be_frozen) + end + + it 'defaults to empty hash' do + elevator = described_class.new(inner_app) + expect { elevator.parse_tenant_name(request_for('anything.com')) } + .to(raise_error(Apartment::TenantNotFound)) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/elevators/host_hash_spec.rb` +Expected: Failures (constructor uses positional args) + +- [ ] **Step 3: Update HostHash implementation** + +Replace `lib/apartment/elevators/host_hash.rb`: + +```ruby +# frozen_string_literal: true + +require 'apartment/elevators/generic' + +module Apartment + module Elevators + # Tenant from hostname -> tenant hash mapping. + # Raises TenantNotFound when host is not in the hash (explicit mapping; missing = config error). + class HostHash < Generic + def initialize(app, hash: {}, **_options) + super(app) + @hash = hash.freeze + end + + def parse_tenant_name(request) + raise(TenantNotFound, request.host) unless @hash.key?(request.host) + + @hash[request.host] + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/elevators/host_hash_spec.rb` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/elevators/host_hash.rb spec/unit/elevators/host_hash_spec.rb +git commit -m "v4 HostHash elevator: keyword args, drop positional processor" +``` + +--- + +## Task 7: Header elevator (new) + +**Files:** +- Create: `lib/apartment/elevators/header.rb` +- Create: `spec/unit/elevators/header_spec.rb` + +- [ ] **Step 1: Write failing tests for Header** + +Create `spec/unit/elevators/header_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/header' + +RSpec.describe(Apartment::Elevators::Header) do + let(:inner_app) { ->(env) { [200, {}, ['ok']] } } + + def env_with_header(header_name, value) + rack_key = "HTTP_#{header_name.upcase.tr('-', '_')}" + Rack::MockRequest.env_for('http://example.com/', rack_key => value) + end + + describe '#parse_tenant_name' do + it 'extracts tenant from the default X-Tenant-Id header' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(env_with_header('X-Tenant-Id', 'acme')) + expect(elevator.parse_tenant_name(request)).to(eq('acme')) + end + + it 'extracts tenant from a custom header' do + elevator = described_class.new(inner_app, header: 'X-CampusESP-Tenant') + request = Rack::Request.new(env_with_header('X-CampusESP-Tenant', 'widgets')) + expect(elevator.parse_tenant_name(request)).to(eq('widgets')) + end + + it 'returns nil when header is missing' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('http://example.com/')) + expect(elevator.parse_tenant_name(request)).to(be_nil) + end + + it 'handles header names with mixed case' do + elevator = described_class.new(inner_app, header: 'x-tenant-id') + request = Rack::Request.new(env_with_header('X-Tenant-Id', 'acme')) + expect(elevator.parse_tenant_name(request)).to(eq('acme')) + end + + it 'accepts trusted: without affecting behavior' do + elevator = described_class.new(inner_app, trusted: true) + request = Rack::Request.new(env_with_header('X-Tenant-Id', 'acme')) + expect(elevator.parse_tenant_name(request)).to(eq('acme')) + end + end + + describe '#call' do + it 'switches tenant when header is present' do + elevator = described_class.new(inner_app) + expect(Apartment::Tenant).to(receive(:switch).with('acme').and_yield) + + elevator.call(env_with_header('X-Tenant-Id', 'acme')) + end + + it 'does not switch when header is absent' do + elevator = described_class.new(inner_app) + expect(Apartment::Tenant).not_to(receive(:switch)) + + elevator.call(Rack::MockRequest.env_for('http://example.com/')) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/elevators/header_spec.rb` +Expected: LoadError (file doesn't exist yet) + +- [ ] **Step 3: Implement Header elevator** + +Create `lib/apartment/elevators/header.rb`: + +```ruby +# frozen_string_literal: true + +require 'apartment/elevators/generic' + +module Apartment + module Elevators + # Tenant from HTTP header. For infrastructure that injects tenant identity at the edge + # (CloudFront, Nginx, API gateway). + # + # The trusted: flag is consumed by the Railtie for a boot-time warning; + # the elevator itself behaves identically regardless of trust level. + class Header < Generic + attr_reader :raw_header + + def initialize(app, header: 'X-Tenant-Id', **_options) + super(app) + @header_name = "HTTP_#{header.upcase.tr('-', '_')}" + @raw_header = header.freeze + end + + def parse_tenant_name(request) + request.get_header(@header_name) + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/elevators/header_spec.rb` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/elevators/header.rb spec/unit/elevators/header_spec.rb +git commit -m "Add v4 Header elevator: HTTP header-based tenant resolution" +``` + +--- + +## Task 8: Railtie changes + +**Files:** +- Modify: `lib/apartment/railtie.rb` +- Modify: `spec/unit/railtie_spec.rb` + +- [ ] **Step 1: Write failing tests for Railtie changes** + +Add to `spec/unit/railtie_spec.rb` inside the `describe '.resolve_elevator_class'` block: + +```ruby +it 'passes through a class without resolution' do + klass = Apartment::Railtie.resolve_elevator_class(Apartment::Elevators::Subdomain) + expect(klass).to(eq(Apartment::Elevators::Subdomain)) +end + +it 'passes through any custom class' do + custom_class = Class.new(Apartment::Elevators::Generic) + klass = Apartment::Railtie.resolve_elevator_class(custom_class) + expect(klass).to(eq(custom_class)) +end + +it 'resolves :header to Apartment::Elevators::Header' do + klass = Apartment::Railtie.resolve_elevator_class(:header) + expect(klass).to(eq(Apartment::Elevators::Header)) +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/railtie_spec.rb` +Expected: Failure (resolve_elevator_class doesn't handle class input) + +- [ ] **Step 3: Update Railtie implementation** + +Update `lib/apartment/railtie.rb`: + +1. `resolve_elevator_class` — add class pass-through at top: +```ruby +def self.resolve_elevator_class(elevator) + return elevator if elevator.is_a?(Class) + + class_name = "Apartment::Elevators::#{elevator.to_s.camelize}" + require("apartment/elevators/#{elevator}") + class_name.constantize +rescue NameError, LoadError => e + available = Dir[File.join(__dir__, 'elevators', '*.rb')] + .filter_map { |f| name = File.basename(f, '.rb'); name unless name == 'generic' } + raise(Apartment::ConfigurationError, + "Unknown elevator '#{elevator}': #{e.message}. " \ + "Available elevators: #{available.join(', ')}") +end +``` + +2. Middleware initializer — `**opts` + Header warning: +```ruby +initializer 'apartment.middleware' do |app| + next unless Apartment.config&.elevator + + elevator_class = Apartment::Railtie.resolve_elevator_class(Apartment.config.elevator) + opts = Apartment.config.elevator_options || {} + + if elevator_class <= Apartment::Elevators::Header && !opts[:trusted] + warn <<~WARNING + [Apartment] WARNING: Header elevator with trusted: false. + Header-based tenant resolution trusts the client to provide the correct tenant. + Only use this when the header is injected by trusted infrastructure (CDN, reverse proxy) + that strips client-supplied values. + WARNING + end + + app.middleware.use(elevator_class, **opts) +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/railtie_spec.rb` +Expected: All pass + +- [ ] **Step 5: Run full unit test suite** + +Run: `bundle exec rspec spec/unit/` +Expected: All pass + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/railtie.rb spec/unit/railtie_spec.rb +git commit -m "v4 Railtie: keyword args, class pass-through, Header trust warning" +``` + +--- + +## Task 9: Header integration test + full suite green + CLAUDE.md updates + +**Files:** +- Modify: `spec/integration/v4/request_lifecycle_spec.rb` (add Header elevator scenario) +- Modify: `lib/apartment/elevators/CLAUDE.md` (update for v4 changes) +- Modify: `spec/CLAUDE.md` (add elevator test references) +- Modify: `lib/apartment/CLAUDE.md` (update elevator section) + +- [ ] **Step 1: Add Header elevator integration test** + +Add to `spec/integration/v4/request_lifecycle_spec.rb`, inside the describe block: + +```ruby +context 'with Header elevator' do + around do |example| + # Temporarily swap middleware to Header elevator for this test. + # The dummy app uses Subdomain by default; we insert Header before it. + Rails.application.middleware.use(Apartment::Elevators::Header, header: 'X-Tenant-Id') + example.run + ensure + Rails.application.middleware.delete(Apartment::Elevators::Header) + end + + it 'switches tenant based on X-Tenant-Id header' do + header 'X-Tenant-Id', 'acme' + header 'Host', 'example.com' # no subdomain, so Subdomain elevator returns nil + get '/tenant_info' + expect(last_response).to(be_ok) + body = JSON.parse(last_response.body) + expect(body['tenant']).to(eq('acme')) + end + + it 'falls through to default tenant when header is absent' do + header 'Host', 'example.com' + get '/tenant_info' + expect(last_response).to(be_ok) + body = JSON.parse(last_response.body) + expect(body['tenant']).to(eq('public')) + end +end +``` + +- [ ] **Step 2: Run full unit tests** + +Run: `bundle exec rspec spec/unit/` +Expected: All pass + +- [ ] **Step 3: Run full unit tests across Rails versions** + +Run: `bundle exec appraisal rspec spec/unit/` +Expected: All pass + +- [ ] **Step 4: Run integration tests (if PostgreSQL available)** + +Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/request_lifecycle_spec.rb` +Expected: All pass (existing subdomain tests + new Header tests) + +- [ ] **Step 5: Run rubocop** + +Run: `bundle exec rubocop lib/apartment/elevators/ spec/unit/elevators/` +Expected: No offenses (fix any that appear) + +- [ ] **Step 6: Update CLAUDE.md files** + +Update `lib/apartment/elevators/CLAUDE.md` to reflect v4 changes (constructor-only config, Header elevator, no class-level setters). + +Update `spec/CLAUDE.md` to reference `spec/unit/elevators/` test files. + +Update `lib/apartment/CLAUDE.md` elevator section note. + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/elevators/CLAUDE.md spec/CLAUDE.md lib/apartment/CLAUDE.md +git commit -m "Update CLAUDE.md files for v4 elevators" +``` + +--- + +## Task 10: Branch + PR + +- [ ] **Step 1: Create feature branch (if not already on one)** + +The work should be on a `man/v4-elevators` branch off `development`. If still on `development`, create the branch now and cherry-pick the commits. + +- [ ] **Step 2: Push and create PR** + +```bash +git push -u origin man/v4-elevators +gh pr create --base development --title "Phase 3: v4 Elevators" --body "..." +``` + +PR body should reference the design spec and list the changes. diff --git a/docs/plans/v4-migrations.md b/docs/plans/v4-migrations.md new file mode 100644 index 00000000..a6de75bb --- /dev/null +++ b/docs/plans/v4-migrations.md @@ -0,0 +1,1207 @@ +# v4 Migrations (Phase 4) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement `Apartment::Migrator` for parallel tenant migrations with RBAC credential separation, plus schema dumper patch and rake task integration. + +**Architecture:** Standalone Migrator with its own ephemeral PoolManager instance. Thread-based parallelism via `Queue`. `Data.define` for immutable result tracking. Schema dumper patch conditionally applied for Rails 8.1+. Rake tasks delegate to Migrator; Railtie enhances `db:migrate:DBNAME`. + +**Tech Stack:** Ruby 3.2+ (`Data.define`), `concurrent-ruby` (`Concurrent::Array`), ActiveRecord `MigrationContext`, ActiveSupport::Notifications + +**Spec:** `docs/designs/v4-migrations.md` + +--- + +## File Structure + +``` +lib/apartment/ +├── migrator.rb # NEW — Migrator, Result, MigrationRun (orchestration + result types) +├── schema_dumper_patch.rb # NEW — Rails 8.1 public. prefix stripping for SchemaDumper +├── config.rb # MODIFY — add migration_db_config, schema_cache_per_tenant; remove parallel_strategy +├── errors.rb # MODIFY — add MigrationError +├── tasks/v4.rake # MODIFY — wire apartment:migrate/rollback through Migrator +├── railtie.rb # MODIFY — add db:migrate:DBNAME enhancement hook +├── instrumentation.rb # NO CHANGE — existing instrument() supports new events + +spec/unit/ +├── migrator_spec.rb # NEW — Migrator core logic, threading, results +├── schema_dumper_patch_spec.rb # NEW — prefix stripping logic + +spec/integration/v4/ +├── migrator_integration_spec.rb # NEW — end-to-end migration with real databases +``` + +--- + +### Task 1: Config Changes — Remove `parallel_strategy`, Add New Keys + +**Files:** +- Modify: `lib/apartment/config.rb:12,16,39-40,60-67,94-101,105-139` +- Modify: `spec/unit/config_spec.rb:19,30-40` + +- [ ] **Step 1: Write failing tests for new config keys** + +Add to `spec/unit/config_spec.rb`: + +```ruby +# In 'defaults' describe block: +it { expect(config.migration_db_config).to(be_nil) } +it { expect(config.schema_cache_per_tenant).to(be(false)) } + +# Remove this line: +# it { expect(config.parallel_strategy).to(eq(:auto)) } + +# New describe block: +describe '#migration_db_config=' do + it 'accepts nil' do + config.migration_db_config = nil + expect(config.migration_db_config).to(be_nil) + end + + it 'accepts a symbol' do + config.migration_db_config = :db_manager + expect(config.migration_db_config).to(eq(:db_manager)) + end + + it 'rejects non-symbol, non-nil values' do + expect { config.migration_db_config = 'db_manager' }.to(raise_error( + Apartment::ConfigurationError, /migration_db_config must be nil or a Symbol/ + )) + end +end + +describe '#schema_cache_per_tenant=' do + it 'accepts boolean values' do + config.schema_cache_per_tenant = true + expect(config.schema_cache_per_tenant).to(be(true)) + end +end +``` + +Remove the `#parallel_strategy=` test block entirely. + +- [ ] **Step 2: Run tests to verify failures** + +Run: `bundle exec rspec spec/unit/config_spec.rb -v` +Expected: Failures for new config keys, possible failure from removed parallel_strategy test + +- [ ] **Step 3: Implement config changes** + +In `lib/apartment/config.rb`: + +1. Remove `VALID_PARALLEL_STRATEGIES` constant (line 12) +2. Remove `parallel_strategy` from `attr_reader` (line 16) +3. Add `migration_db_config` and `schema_cache_per_tenant` to `attr_accessor` (line 22 area) +4. In `initialize`: remove `@parallel_strategy = :auto` (line 40), add `@migration_db_config = nil` and `@schema_cache_per_tenant = false` +5. Remove `parallel_strategy=` setter method (lines 60-67) +6. Add `migration_db_config=` setter with validation: + +```ruby +def migration_db_config=(value) + unless value.nil? || value.is_a?(Symbol) + raise(ConfigurationError, "migration_db_config must be nil or a Symbol referencing a database.yml config, " \ + "got: #{value.inspect}") + end + + @migration_db_config = value +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/config_spec.rb -v` +Expected: All pass + +- [ ] **Step 5: Run full unit suite to check for breakage** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass. If any specs reference `parallel_strategy`, update them. + +- [ ] **Step 6: Commit** + +```bash +git add lib/apartment/config.rb spec/unit/config_spec.rb +git commit -m "Config: add migration_db_config, schema_cache_per_tenant; remove parallel_strategy" +``` + +--- + +### Task 2: Add MigrationError to Error Hierarchy + +**Files:** +- Modify: `lib/apartment/errors.rb:37` +- Modify: `spec/unit/errors_spec.rb` (if exists, otherwise skip test file) + +- [ ] **Step 1: Add error class** + +Append to `lib/apartment/errors.rb` before the closing `end`: + +```ruby +# Raised when a tenant migration fails. Wraps the original exception. +class MigrationError < ApartmentError + attr_reader :tenant, :original_error + + def initialize(tenant, original_error) + @tenant = tenant + @original_error = original_error + super("Migration failed for tenant '#{tenant}': #{original_error.class}: #{original_error.message}") + end +end +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/apartment/errors.rb +git commit -m "Add Apartment::MigrationError for per-tenant migration failures" +``` + +--- + +### Task 3: Result and MigrationRun Value Objects + +**Files:** +- Create: `lib/apartment/migrator.rb` (partial — just the value objects first) +- Create: `spec/unit/migrator_spec.rb` (partial — result tracking tests) + +- [ ] **Step 1: Write failing tests for Result and MigrationRun** + +Create `spec/unit/migrator_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/migrator' + +RSpec.describe(Apartment::Migrator::Result) do + subject(:result) do + described_class.new( + tenant: 'acme', + status: :success, + duration: 1.23, + error: nil, + versions_run: [20260401000000, 20260402000000] + ) + end + + it 'is frozen (Data.define)' do + expect(result).to(be_frozen) + end + + it 'exposes all attributes' do + expect(result.tenant).to(eq('acme')) + expect(result.status).to(eq(:success)) + expect(result.duration).to(eq(1.23)) + expect(result.error).to(be_nil) + expect(result.versions_run).to(eq([20260401000000, 20260402000000])) + end +end + +RSpec.describe(Apartment::Migrator::MigrationRun) do + let(:success_result) do + Apartment::Migrator::Result.new( + tenant: 'acme', status: :success, duration: 1.0, error: nil, versions_run: [1] + ) + end + let(:failed_result) do + Apartment::Migrator::Result.new( + tenant: 'broken', status: :failed, duration: 0.5, + error: StandardError.new('boom'), versions_run: [] + ) + end + let(:skipped_result) do + Apartment::Migrator::Result.new( + tenant: 'current', status: :skipped, duration: 0.01, error: nil, versions_run: [] + ) + end + + subject(:run) do + described_class.new( + results: [success_result, failed_result, skipped_result], + total_duration: 2.5, + threads: 4 + ) + end + + describe '#succeeded' do + it 'returns only success results' do + expect(run.succeeded.map(&:tenant)).to(eq(['acme'])) + end + end + + describe '#failed' do + it 'returns only failed results' do + expect(run.failed.map(&:tenant)).to(eq(['broken'])) + end + end + + describe '#skipped' do + it 'returns only skipped results' do + expect(run.skipped.map(&:tenant)).to(eq(['current'])) + end + end + + describe '#success?' do + it 'returns false when there are failures' do + expect(run.success?).to(be(false)) + end + + it 'returns true when no failures' do + all_good = described_class.new( + results: [success_result, skipped_result], total_duration: 1.0, threads: 2 + ) + expect(all_good.success?).to(be(true)) + end + end + + describe '#summary' do + it 'includes counts and timing' do + summary = run.summary + expect(summary).to(include('3 tenants')) + expect(summary).to(include('2.5s')) + expect(summary).to(include('1 succeeded')) + expect(summary).to(include('1 failed')) + expect(summary).to(include('1 skipped')) + expect(summary).to(include('broken')) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: LoadError or NameError (file doesn't exist yet) + +- [ ] **Step 3: Implement Result and MigrationRun** + +Create `lib/apartment/migrator.rb`: + +```ruby +# frozen_string_literal: true + +require 'concurrent' +require_relative 'pool_manager' +require_relative 'instrumentation' +require_relative 'errors' + +module Apartment + class Migrator + Result = Data.define( + :tenant, + :status, + :duration, + :error, + :versions_run + ) + + MigrationRun = Data.define( + :results, + :total_duration, + :threads + ) do + def succeeded = results.select { _1.status == :success } + def failed = results.select { _1.status == :failed } + def skipped = results.select { _1.status == :skipped } + def success? = failed.empty? + + def summary + lines = [] + lines << "Migrated #{results.size} tenants in #{total_duration.round(1)}s (#{threads} threads)" + lines << " #{succeeded.size} succeeded" if succeeded.any? + lines << " #{failed.size} failed: [#{failed.map(&:tenant).join(', ')}]" if failed.any? + lines << " #{skipped.size} skipped (up to date)" if skipped.any? + lines.join("\n") + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/migrator.rb spec/unit/migrator_spec.rb +git commit -m "Add Migrator::Result and MigrationRun value objects" +``` + +--- + +### Task 4: Migrator Core — Config Resolution and Credential Overlay + +**Files:** +- Modify: `lib/apartment/migrator.rb` +- Modify: `spec/unit/migrator_spec.rb` + +- [ ] **Step 1: Write failing tests for credential overlay** + +Add to `spec/unit/migrator_spec.rb`: + +```ruby +RSpec.describe(Apartment::Migrator) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + describe '#resolve_migration_config' do + let(:migrator) { described_class.new(threads: 0) } + + it 'returns base config when migration_db_config is nil' do + # Adapters return string-keyed hashes (via base_config/transform_keys) + base = { 'adapter' => 'postgresql', 'database' => 'app_db', 'schema_search_path' => 'acme' } + result = migrator.send(:resolve_migration_config, base, nil) + expect(result).to(eq(base)) + end + + it 'overlays credentials from migration_db_config' do + # Base config: string keys (from adapter) + base = { 'adapter' => 'postgresql', 'database' => 'app_db', 'schema_search_path' => 'acme', + 'username' => 'app_user', 'password' => 'app_pass' } + + # Migration config: symbol keys (from configuration_hash) + migration_config = { adapter: 'postgresql', database: 'app_db', + username: 'db_manager', password: 'mgr_pass' } + + result = migrator.send(:resolve_migration_config, base, migration_config) + + expect(result['username']).to(eq('db_manager')) + expect(result['password']).to(eq('mgr_pass')) + expect(result['schema_search_path']).to(eq('acme')) + expect(result['database']).to(eq('app_db')) + end + + it 'overlays host when migration config specifies one' do + base = { 'adapter' => 'postgresql', 'host' => 'app-host', 'username' => 'app' } + migration_config = { adapter: 'postgresql', host: 'admin-host', username: 'admin', password: 'pass' } + + result = migrator.send(:resolve_migration_config, base, migration_config) + expect(result['host']).to(eq('admin-host')) + end + end + + describe '#initialize' do + it 'defaults to 0 threads' do + migrator = described_class.new + expect(migrator.instance_variable_get(:@threads)).to(eq(0)) + end + + it 'accepts threads parameter' do + migrator = described_class.new(threads: 8) + expect(migrator.instance_variable_get(:@threads)).to(eq(8)) + end + + it 'accepts migration_db_config parameter' do + migrator = described_class.new(migration_db_config: :db_manager) + expect(migrator.instance_variable_get(:@migration_db_config)).to(eq(:db_manager)) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: NoMethodError for `initialize` params and `resolve_migration_config` + +- [ ] **Step 3: Implement constructor and credential overlay** + +Add to `lib/apartment/migrator.rb` inside the `Migrator` class, after the `MigrationRun` definition: + +```ruby + CREDENTIAL_KEYS = %i[username password host].freeze + + def initialize(threads: 0, migration_db_config: nil) + @threads = threads + @migration_db_config = migration_db_config + @pool_manager = PoolManager.new + end + + private + + # Overlay migration credentials onto a tenant's base connection config. + # Only credential keys (username, password, host) are merged; everything + # else (database, schema_search_path, port, adapter) comes from the base. + # Note: base_config has string keys (from adapter's base_config/transform_keys), + # while migration_config has symbol keys (from configuration_hash). We normalize + # the overlay to string keys before merging. + def resolve_migration_config(base_config, migration_config) + return base_config unless migration_config + + overlay = migration_config.slice(*CREDENTIAL_KEYS).compact + base_config.merge(overlay.transform_keys(&:to_s)) + end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/migrator.rb spec/unit/migrator_spec.rb +git commit -m "Migrator: constructor and credential overlay logic" +``` + +--- + +### Task 5: Migrator Core — `migrate_tenant` and `#run` (Sequential) + +**Files:** +- Modify: `lib/apartment/migrator.rb` +- Modify: `spec/unit/migrator_spec.rb` + +- [ ] **Step 1: Write failing tests for migrate_tenant and sequential run** + +Add to the `Apartment::Migrator` describe block in `spec/unit/migrator_spec.rb`: + +```ruby + describe '#run' do + let(:migrator) { described_class.new(threads: 0) } + let(:mock_adapter) { instance_double('Apartment::Adapters::AbstractAdapter') } + let(:mock_migration_context) { instance_double('ActiveRecord::MigrationContext') } + let(:mock_pool) { instance_double('ActiveRecord::ConnectionAdapters::ConnectionPool') } + let(:mock_connection) { double('connection') } + let(:mock_schema_migration) { double('schema_migration') } + + before do + allow(Apartment).to(receive(:adapter).and_return(mock_adapter)) + allow(mock_adapter).to(receive(:resolve_connection_config)) + .with('acme').and_return({ adapter: 'postgresql', schema_search_path: 'acme' }) + allow(mock_adapter).to(receive(:resolve_connection_config)) + .with('beta').and_return({ adapter: 'postgresql', schema_search_path: 'beta' }) + + # Stub pool creation and migration context + allow(ActiveRecord::Base).to(receive(:connection_handler)) + .and_return(double('handler')) + allow_any_instance_of(PoolManager).to(receive(:fetch_or_create)).and_return(mock_pool) + allow(mock_pool).to(receive(:with_connection).and_yield(mock_connection)) + allow(mock_pool).to(receive(:schema_migration).and_return(mock_schema_migration)) + allow(mock_pool).to(receive(:migration_context).and_return(mock_migration_context)) + allow(mock_pool).to(receive(:disconnect!)) + allow(mock_migration_context).to(receive(:needs_migration?).and_return(true)) + allow(mock_migration_context).to(receive(:migrate).and_return([])) + end + + it 'returns a MigrationRun with results for all tenants' do + result = migrator.run + expect(result).to(be_a(Apartment::Migrator::MigrationRun)) + expect(result.results.map(&:tenant)).to(contain_exactly('acme', 'beta')) + end + + it 'returns :skipped for tenants with no pending migrations' do + allow(mock_migration_context).to(receive(:needs_migration?).and_return(false)) + result = migrator.run + expect(result.results.map(&:status)).to(all(eq(:skipped))) + end + + it 'captures errors without halting the run' do + call_count = 0 + allow(mock_migration_context).to(receive(:migrate)) do + call_count += 1 + raise(ActiveRecord::StatementInvalid, 'boom') if call_count == 1 + [] + end + result = migrator.run + expect(result.failed.size).to(eq(1)) + expect(result.succeeded.size).to(eq(1)) + end + + it 'instruments each tenant migration' do + expect(Apartment::Instrumentation).to(receive(:instrument) + .with(:migrate_tenant, hash_including(:tenant)).twice) + migrator.run + end + + it 'clears the pool manager after run' do + expect_any_instance_of(PoolManager).to(receive(:clear)) + migrator.run + end + + it 'handles empty tenant list' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + result = migrator.run + # Only the primary result (Phase 1) should be present + expect(result.results.size).to(eq(1)) + end + + it 'includes primary database result as first entry' do + result = migrator.run + expect(result.results.first.tenant).to(eq('public')) + end + end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: NoMethodError for `#run` + +- [ ] **Step 3: Implement `migrate_tenant` and `#run`** + +Add to `lib/apartment/migrator.rb`, in the public section of the class: + +```ruby + def run + start = monotonic_now + + # Phase 1: Migrate primary database (blocking, before tenants) + primary_result = migrate_primary + + # Phase 2: Migrate tenants + tenants = Apartment.config.tenants_provider.call + tenant_results = if @threads > 0 + run_parallel(tenants) + else + run_sequential(tenants) + end + + all_results = [primary_result, *tenant_results].compact + + MigrationRun.new( + results: all_results, + total_duration: monotonic_now - start, + threads: @threads + ) + ensure + @pool_manager.clear + end +``` + +Add to the private section: + +```ruby + # Phase 1: Migrate the primary database (public schema for PG, primary DB for MySQL/SQLite). + # Uses migration_db_config credentials if configured. Returns nil if no pending migrations. + def migrate_primary + start = monotonic_now + default = Apartment.config.default_tenant || 'public' + + migration_config = resolve_migration_db_config + config = if migration_config + Apartment.adapter.base_config.merge( + migration_config.slice(*CREDENTIAL_KEYS).compact.transform_keys(&:to_s) + ) + else + Apartment.adapter.base_config + end + + pool = @pool_manager.fetch_or_create("__primary__") { create_pool(config) } + context = pool.migration_context + + unless context.needs_migration? + return Result.new( + tenant: default, status: :skipped, duration: monotonic_now - start, + error: nil, versions_run: [] + ) + end + + versions = context.migrate + Instrumentation.instrument(:migrate_tenant, tenant: default, versions: versions) + + Result.new( + tenant: default, status: :success, duration: monotonic_now - start, + error: nil, versions_run: Array(versions).map { _1.respond_to?(:version) ? _1.version : _1 } + ) + rescue StandardError => e + Instrumentation.instrument(:migrate_tenant, tenant: default, error: e) + Result.new( + tenant: default, status: :failed, duration: monotonic_now - start, + error: e, versions_run: [] + ) + end + + def run_sequential(tenants) + tenants.map { |tenant| migrate_tenant(tenant) } + end + + def migrate_tenant(tenant) + start = monotonic_now + + base_config = Apartment.adapter.resolve_connection_config(tenant) + migration_config = resolve_migration_db_config + config = resolve_migration_config(base_config, migration_config) + + pool = @pool_manager.fetch_or_create(tenant) do + create_pool(config) + end + + context = pool.migration_context + unless context.needs_migration? + return Result.new( + tenant: tenant, status: :skipped, duration: monotonic_now - start, + error: nil, versions_run: [] + ) + end + + versions = context.migrate + Instrumentation.instrument(:migrate_tenant, tenant: tenant, versions: versions) + + Result.new( + tenant: tenant, status: :success, duration: monotonic_now - start, + error: nil, versions_run: Array(versions).map { _1.respond_to?(:version) ? _1.version : _1 } + ) + rescue StandardError => e + Instrumentation.instrument(:migrate_tenant, tenant: tenant, error: e) + Result.new( + tenant: tenant, status: :failed, duration: monotonic_now - start, + error: e, versions_run: [] + ) + end + + def resolve_migration_db_config + return nil unless @migration_db_config + + db_config = ActiveRecord::Base.configurations.configs_for( + env_name: Apartment.config.rails_env_name, + name: @migration_db_config.to_s + ) + raise(ConfigurationError, "migration_db_config '#{@migration_db_config}' not found in database.yml") unless db_config + + db_config.configuration_hash + end + + def create_pool(config) + # Build a DatabaseConfig and register a pool via AR's ConnectionHandler. + # This is an ephemeral pool — the Migrator's PoolManager tracks it, + # and #run ensures cleanup via @pool_manager.clear. + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + Apartment.config.rails_env_name, + "apartment_migrate_#{config[:schema_search_path] || config[:database]}", + config + ) + handler = ActiveRecord::Base.connection_handler + handler.establish_connection(db_config) + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end +``` + +**Implementation notes for `create_pool`:** +- The exact implementation may need adjustment based on how AR's ConnectionHandler works in the target Rails versions. The test stubs this method, so the unit tests validate the orchestration logic independently. +- `establish_connection` registers the pool in AR's global ConnectionHandler. When `@pool_manager.clear` disconnects these pools, the handler still holds stale references. The implementing agent should investigate using a dedicated ConnectionHandler instance (`ActiveRecord::ConnectionAdapters::ConnectionHandler.new`) for the Migrator's pools, or explicitly deregistering pools from the handler during cleanup. + +**Deferred from this plan (implement in later tasks or phases):** +- Schema cache per-tenant generation (`schema_cache_per_tenant` config is added but generation logic is not implemented; callers like `release.rb` control cache generation) +- RBAC integration tests (require real `db_manager` role setup in CI) +- Partial failure integration tests (require a way to inject broken migrations per-tenant) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/migrator.rb spec/unit/migrator_spec.rb +git commit -m "Migrator: sequential #run with per-tenant result tracking" +``` + +--- + +### Task 6: Migrator Core — Parallel Execution + +**Files:** +- Modify: `lib/apartment/migrator.rb` +- Modify: `spec/unit/migrator_spec.rb` + +- [ ] **Step 1: Write failing tests for parallel execution** + +Add to `spec/unit/migrator_spec.rb`: + +```ruby + describe '#run with threads > 0' do + let(:migrator) { described_class.new(threads: 4) } + + before do + allow(Apartment).to(receive(:adapter).and_return(mock_adapter)) + # Stub for 8 tenants to exercise parallelism + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { (1..8).map { |i| "tenant_#{i}" } } + c.default_tenant = 'public' + end + + allow(mock_adapter).to(receive(:resolve_connection_config)) do |tenant| + { adapter: 'postgresql', schema_search_path: tenant } + end + allow_any_instance_of(PoolManager).to(receive(:fetch_or_create).and_return(mock_pool)) + allow(mock_pool).to(receive(:migration_context).and_return(mock_migration_context)) + allow(mock_pool).to(receive(:disconnect!)) + allow(mock_migration_context).to(receive(:needs_migration?).and_return(true)) + allow(mock_migration_context).to(receive(:migrate).and_return([])) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow_any_instance_of(PoolManager).to(receive(:clear)) + end + + it 'migrates all tenants' do + result = migrator.run + expect(result.results.size).to(eq(8)) + end + + it 'records thread count in MigrationRun' do + result = migrator.run + expect(result.threads).to(eq(4)) + end + end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: Failure (run_parallel not implemented) + +- [ ] **Step 3: Implement parallel execution** + +Add to the private section of `lib/apartment/migrator.rb`: + +```ruby + def run_parallel(tenants) + work_queue = Queue.new + tenants.each { |t| work_queue << t } + @threads.times { work_queue << :done } + + results = Concurrent::Array.new + + workers = @threads.times.map do + Thread.new do + while (tenant = work_queue.pop) != :done + results << migrate_tenant(tenant) + end + end + end + + workers.each(&:join) + results.to_a + end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/migrator_spec.rb -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/migrator.rb spec/unit/migrator_spec.rb +git commit -m "Migrator: thread-based parallel execution via Queue" +``` + +--- + +### Task 7: Schema Dumper Patch + +**Files:** +- Create: `lib/apartment/schema_dumper_patch.rb` +- Create: `spec/unit/schema_dumper_patch_spec.rb` + +- [ ] **Step 1: Write failing tests for prefix stripping** + +Create `spec/unit/schema_dumper_patch_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/schema_dumper_patch' + +RSpec.describe(Apartment::SchemaDumperPatch) do + describe '.strip_public_prefix' do + it 'strips public. prefix from table name' do + expect(described_class.strip_public_prefix('public.users')).to(eq('users')) + end + + it 'leaves non-public schemas intact' do + expect(described_class.strip_public_prefix('extensions.uuid_ossp')).to(eq('extensions.uuid_ossp')) + end + + it 'leaves unqualified names unchanged' do + expect(described_class.strip_public_prefix('users')).to(eq('users')) + end + + it 'respects include_schemas_in_dump' do + # When 'shared' is in include list, shared.foo stays as-is + expect(described_class.strip_public_prefix('shared.lookups', include_schemas: %w[shared])) + .to(eq('shared.lookups')) + end + + it 'strips public. even when include_schemas is set' do + expect(described_class.strip_public_prefix('public.users', include_schemas: %w[shared])) + .to(eq('users')) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bundle exec rspec spec/unit/schema_dumper_patch_spec.rb -v` +Expected: LoadError (file doesn't exist) + +- [ ] **Step 3: Implement the patch** + +Create `lib/apartment/schema_dumper_patch.rb`: + +```ruby +# frozen_string_literal: true + +module Apartment + # Patches ActiveRecord::SchemaDumper to strip 'public.' prefix from table + # names in schema.rb output. Applied conditionally for Rails 8.1+ where + # schema-qualified table names were introduced. + # + # This ensures schema.rb can be loaded into any PostgreSQL schema without + # tables being created in 'public' instead of the target schema. + module SchemaDumperPatch + def self.strip_public_prefix(table_name, include_schemas: []) + schema, name = table_name.split('.', 2) + + # No schema qualifier — return as-is + return table_name unless name + + # Non-public schema that's in the include list — keep qualified + return table_name if schema != 'public' && include_schemas.include?(schema) + + # Public schema — strip prefix + return name if schema == 'public' + + # Non-public schema not in include list — keep qualified + table_name + end + + def self.apply! + return unless should_patch? + + ActiveRecord::SchemaDumper.prepend(DumperOverride) + end + + def self.should_patch? + return false unless defined?(ActiveRecord::SchemaDumper) + + ActiveRecord.gem_version >= Gem::Version.new('8.1.0') + end + + module DumperOverride + private + + def table(table_name, stream) + include_schemas = Apartment.config&.postgres_config&.include_schemas_in_dump || [] + stripped = SchemaDumperPatch.strip_public_prefix(table_name, include_schemas: include_schemas) + super(stripped, stream) + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bundle exec rspec spec/unit/schema_dumper_patch_spec.rb -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add lib/apartment/schema_dumper_patch.rb spec/unit/schema_dumper_patch_spec.rb +git commit -m "Schema dumper patch: strip public. prefix for Rails 8.1+" +``` + +--- + +### Task 8: Wire Rake Tasks Through Migrator + +**Files:** +- Modify: `lib/apartment/tasks/v4.rake:24-58` + +- [ ] **Step 1: Write the updated rake tasks** + +Replace the `apartment:migrate` and `apartment:rollback` tasks in `lib/apartment/tasks/v4.rake`: + +```ruby +desc 'Run migrations for all tenants' +task migrate: :environment do + require 'apartment/migrator' + + threads = Apartment.config.parallel_migration_threads + migration_db_config = Apartment.config.migration_db_config + + migrator = Apartment::Migrator.new( + threads: threads, + migration_db_config: migration_db_config + ) + + result = migrator.run + puts result.summary + + unless result.success? + abort "apartment:migrate failed for #{result.failed.size} tenant(s)" + end + + # Schema dump (respects ActiveRecord.dump_schema_after_migration) + if ActiveRecord.dump_schema_after_migration + Rake::Task['db:schema:dump'].invoke if Rake::Task.task_defined?('db:schema:dump') + end +end + +desc 'Rollback migrations for all tenants' +task :rollback, [:step] => :environment do |_t, args| + step = (args[:step] || 1).to_i + tenants = Apartment.config.tenants_provider.call + tenants.each do |tenant| + puts "Rolling back tenant: #{tenant} (#{step} step(s))" + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection_pool.migration_context.rollback(step) + end + rescue StandardError => e + warn " FAILED: #{e.message}" + end +end +``` + +Note: `apartment:rollback` remains sequential (rollback is a rare, careful operation). Only `apartment:migrate` gets the Migrator. + +- [ ] **Step 2: Run existing tests** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass (rake tasks aren't unit-tested directly; they're validated via integration) + +- [ ] **Step 3: Commit** + +```bash +git add lib/apartment/tasks/v4.rake +git commit -m "Rake: wire apartment:migrate through Migrator with parallel support" +``` + +--- + +### Task 9: Railtie Enhancement — `db:migrate:DBNAME` Hook + +**Files:** +- Modify: `lib/apartment/railtie.rb:46-48` + +- [ ] **Step 1: Add the enhancement hook** + +In `lib/apartment/railtie.rb`, expand the `rake_tasks` block: + +```ruby +rake_tasks do + load File.expand_path('tasks/v4.rake', __dir__) + + # Enhance db:migrate:DBNAME to also run apartment:migrate. + # Uses Rake's enhance to append apartment:migrate after the primary + # database migration completes. Wrapped in begin/rescue to handle + # cases where the database doesn't exist yet (db:create). + begin + primary_db_name = ActiveRecord::Base.configurations + .configs_for(env_name: Rails.env) + .find { |c| c.name == 'primary' } + &.name || 'primary' + + if Rake::Task.task_defined?("db:migrate:#{primary_db_name}") + Rake::Task["db:migrate:#{primary_db_name}"].enhance do + Rake::Task['apartment:migrate'].invoke if Rake::Task.task_defined?('apartment:migrate') + end + end + rescue ActiveRecord::NoDatabaseError + # Database doesn't exist yet (e.g., during db:create). Skip enhancement. + end +end +``` + +- [ ] **Step 2: Verify no regressions** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass + +- [ ] **Step 3: Commit** + +```bash +git add lib/apartment/railtie.rb +git commit -m "Railtie: enhance db:migrate:DBNAME to trigger apartment:migrate" +``` + +--- + +### Task 10: Schema Dumper Patch Activation in Railtie + +**Files:** +- Modify: `lib/apartment/railtie.rb` + +- [ ] **Step 1: Apply schema dumper patch during Rails init** + +In the `config.after_initialize` block in `lib/apartment/railtie.rb`, after `Apartment.activate!`: + +```ruby +# Apply schema dumper patch for Rails 8.1+ (public. prefix stripping) +require 'apartment/schema_dumper_patch' +Apartment::SchemaDumperPatch.apply! +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/apartment/railtie.rb +git commit -m "Railtie: activate schema dumper patch on Rails 8.1+" +``` + +--- + +### Task 11: Integration Tests + +**Files:** +- Create: `spec/integration/v4/migrator_integration_spec.rb` + +- [ ] **Step 1: Write integration tests** + +Create `spec/integration/v4/migrator_integration_spec.rb`. This test requires a real database (PostgreSQL or SQLite). Follow the pattern from `spec/integration/v4/tenant_lifecycle_spec.rb`: + +```ruby +# frozen_string_literal: true + +require_relative 'support' + +RSpec.describe('Migrator integration', :v4_integration) do + include V4IntegrationHelper + + before do + establish_default_connection! + Apartment.configure do |c| + c.tenant_strategy = detect_strategy + c.tenants_provider = -> { %w[migrate_a migrate_b migrate_c] } + c.default_tenant = detect_default_tenant + c.parallel_migration_threads = 0 + end + Apartment.activate! + @adapter = build_adapter + %w[migrate_a migrate_b migrate_c].each { |t| safe_create_tenant(t) } + end + + after do + cleanup_tenants!(%w[migrate_a migrate_b migrate_c]) + clear_config + end + + describe 'sequential migration' do + it 'migrates all tenants and returns MigrationRun' do + migrator = Apartment::Migrator.new(threads: 0) + result = migrator.run + + expect(result).to(be_a(Apartment::Migrator::MigrationRun)) + expect(result.results.size).to(eq(3)) + expect(result.threads).to(eq(0)) + end + end + + describe 'parallel migration' do + it 'migrates all tenants with threads' do + migrator = Apartment::Migrator.new(threads: 2) + result = migrator.run + + expect(result.results.size).to(eq(3)) + tenants = result.results.map(&:tenant) + expect(tenants).to(contain_exactly('migrate_a', 'migrate_b', 'migrate_c')) + end + end + + describe 'idempotency' do + it 'returns :skipped on second run' do + Apartment::Migrator.new(threads: 0).run + result = Apartment::Migrator.new(threads: 0).run + + expect(result.results.map(&:status)).to(all(eq(:skipped))) + end + end +end +``` + +Note: The exact helper methods (`establish_default_connection!`, `build_adapter`, `safe_create_tenant`, `cleanup_tenants!`, `clear_config`, `detect_strategy`, `detect_default_tenant`) follow the patterns in `spec/integration/v4/support.rb`. The implementation agent should read that file and adapt. + +- [ ] **Step 2: Run integration tests** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/migrator_integration_spec.rb -v` +Expected: All pass + +- [ ] **Step 3: Commit** + +```bash +git add spec/integration/v4/migrator_integration_spec.rb +git commit -m "Integration tests for Migrator (sequential, parallel, idempotency)" +``` + +--- + +### Task 12: Update CLAUDE.md Files + +**Files:** +- Modify: `lib/apartment/CLAUDE.md` +- Modify: `lib/apartment/tasks/CLAUDE.md` + +- [ ] **Step 1: Update lib/apartment/CLAUDE.md** + +Add `migrator.rb` and `schema_dumper_patch.rb` entries to the directory structure and file descriptions. Add the Migrator to the data flow section. + +- [ ] **Step 2: Update lib/apartment/tasks/CLAUDE.md** + +Note that `apartment:migrate` now delegates to `Apartment::Migrator` with parallel support. + +- [ ] **Step 3: Commit** + +```bash +git add lib/apartment/CLAUDE.md lib/apartment/tasks/CLAUDE.md +git commit -m "Update CLAUDE.md docs for Migrator and schema dumper patch" +``` + +--- + +### Task 13: Full Test Suite Verification + +- [ ] **Step 1: Run full unit suite** + +Run: `bundle exec rspec spec/unit/ -v` +Expected: All pass + +- [ ] **Step 2: Run unit suite across Rails versions** + +Run: `bundle exec appraisal rspec spec/unit/` +Expected: All pass across all appraisal targets + +- [ ] **Step 3: Run integration tests (SQLite)** + +Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/` +Expected: All pass + +- [ ] **Step 4: Run integration tests (PostgreSQL)** + +Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/` +Expected: All pass + +- [ ] **Step 5: Run lint** + +Run: `bundle exec rubocop lib/apartment/migrator.rb lib/apartment/schema_dumper_patch.rb` +Expected: No offenses + +- [ ] **Step 6: Final commit if any fixes needed** + +```bash +git add && git commit -m "Fix lint/test issues from full suite verification" +``` diff --git a/docs/plans/v4-railtie-test-infra.md b/docs/plans/v4-railtie-test-infra.md new file mode 100644 index 00000000..8ad76613 --- /dev/null +++ b/docs/plans/v4-railtie-test-infra.md @@ -0,0 +1,1191 @@ +# v4 Railtie + Test Infrastructure Overhaul — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a minimal v4 Railtie so Apartment works in a real Rails app, add tenant name validation, and overhaul the test infrastructure with ConnectionHandler swap, scenario configs, dummy app upgrade, and coverage tooling. + +**Architecture:** Six task groups executed sequentially. Tasks 1-3 are implementation (validator, config, railtie). Tasks 4-6 are test infrastructure (ConnectionHandler swap, scenario configs, dummy app). Task 7 is coverage tooling. Each group produces a commit. + +**Tech Stack:** Ruby 3.3+, Rails 7.2+, RSpec, ActiveRecord, Rack::Test, SimpleCov, TestProf + +**Spec:** `docs/designs/v4-railtie-test-infra.md` + +--- + +## File Map + +### New files (create) + +| File | Responsibility | +|------|---------------| +| `lib/apartment/tenant_name_validator.rb` | Pure in-memory tenant name format validation per engine | +| `lib/apartment/tasks/v4.rake` | v4 rake tasks (apartment:create, :drop, :migrate, :seed, :rollback) | +| `spec/unit/tenant_name_validator_spec.rb` | Validator unit tests | +| `spec/unit/railtie_spec.rb` | Railtie hook unit tests (mocked Rails) | +| `spec/integration/v4/request_lifecycle_spec.rb` | Dummy app end-to-end request tests | +| `spec/integration/v4/scenarios/postgresql_schema.yml` | PG schema-per-tenant scenario config | +| `spec/integration/v4/scenarios/postgresql_database.yml` | PG database-per-tenant scenario config | +| `spec/integration/v4/scenarios/mysql_database.yml` | MySQL database-per-tenant scenario config | +| `spec/integration/v4/scenarios/sqlite_file.yml` | SQLite file-per-tenant scenario config | +| `spec/dummy/app/controllers/tenants_controller.rb` | Test controller for request lifecycle | + +### Modified files + +| File | Change | +|------|--------| +| `lib/apartment.rb` | Remove `railtie` from Zeitwerk ignore list | +| `lib/apartment/railtie.rb` | Replace v3 with v4 implementation | +| `lib/apartment/config.rb` | Add `schema_load_strategy`, `schema_file` options + validation | +| `lib/apartment/adapters/abstract_adapter.rb` | Add `validated_connection_config`, `import_schema`, call validator in `create` | +| `lib/apartment/patches/connection_handling.rb` | Call `validated_connection_config` instead of `resolve_connection_config` | +| `spec/integration/v4/support.rb` | ConnectionHandler swap, scenario loading helpers | +| `spec/dummy/config/initializers/apartment.rb` | v4 configure block | +| `spec/dummy/config/application.rb` | Remove v3 requires, let Railtie handle middleware | +| `spec/dummy/config/database.yml` | Update to modern format | +| `spec/dummy/config/routes.rb` | Add `/tenant_info` route | +| `spec/dummy/config/environments/test.rb` | Modernize for Rails 7.2+ | +| `Gemfile` | Add simplecov, test-prof, rack-test | + +--- + +## Task 1: Tenant Name Validator + +**Files:** +- Create: `lib/apartment/tenant_name_validator.rb` +- Create: `spec/unit/tenant_name_validator_spec.rb` + +This is a pure module with no dependencies on Apartment internals. Build and test it first in isolation. + +### Implementation + +`lib/apartment/tenant_name_validator.rb`: + +```ruby +# frozen_string_literal: true + +module Apartment + module TenantNameValidator + module_function + + # Validate a tenant name against common and engine-specific rules. + # Raises ConfigurationError on invalid names. Pure in-memory check — no IO. + def validate!(name, strategy:, adapter_name: nil) + validate_common!(name) + case strategy + when :schema + validate_postgresql_identifier!(name) + when :database_name + validate_for_adapter!(name, adapter_name) + end + # :shard and :database_config use common validation only (not yet implemented). + end + + # --- Common rules (all engines) --- + + def validate_common!(name) + raise(ConfigurationError, 'Tenant name must be a String') unless name.is_a?(String) + raise(ConfigurationError, 'Tenant name cannot be empty') if name.empty? + raise(ConfigurationError, "Tenant name contains NUL byte: #{name.inspect}") if name.include?("\x00") + raise(ConfigurationError, "Tenant name contains whitespace: #{name.inspect}") if name.match?(/\s/) + raise(ConfigurationError, "Tenant name too long (#{name.length} chars, max 255): #{name.inspect}") if name.length > 255 + end + + # --- PostgreSQL identifiers (schema names, database names) --- + # Hyphens are allowed — our adapters quote via quote_table_name. + # Cannot start with pg_ (reserved prefix). + + def validate_postgresql_identifier!(name) + if name.length > 63 + raise(ConfigurationError, "PostgreSQL identifier too long (#{name.length} chars, max 63): #{name.inspect}") + end + unless name.match?(/\A[a-zA-Z_][a-zA-Z0-9_-]*\z/) + raise(ConfigurationError, + "Invalid PostgreSQL identifier: #{name.inspect}. " \ + 'Must start with letter/underscore, contain only letters, digits, underscores, hyphens') + end + return unless name.start_with?('pg_') + + raise(ConfigurationError, "Tenant name cannot start with 'pg_' (reserved prefix): #{name.inspect}") + end + + # --- MySQL database names --- + # Max 64 chars, allowed: [a-zA-Z0-9_$-], no leading digit, no trailing dot. + + def validate_mysql_database_name!(name) + if name.length > 64 + raise(ConfigurationError, "MySQL database name too long (#{name.length} chars, max 64): #{name.inspect}") + end + if name.match?(/\A\d/) + raise(ConfigurationError, "MySQL database name cannot start with a digit: #{name.inspect}") + end + if name.end_with?('.') + raise(ConfigurationError, "MySQL database name cannot end with a period: #{name.inspect}") + end + return unless name.match?(%r{[^a-zA-Z0-9_$-]}) + + raise(ConfigurationError, + "Invalid MySQL database name: #{name.inspect}. " \ + 'Allowed characters: letters, digits, underscore, dollar sign, hyphen') + end + + # --- SQLite file paths --- + # No path traversal, filesystem-safe characters. + + def validate_sqlite_path!(name) + if name.include?('..') + raise(ConfigurationError, "SQLite tenant name contains path traversal: #{name.inspect}") + end + return unless name.match?(%r{[/\\]}) + + raise(ConfigurationError, "SQLite tenant name contains path separators: #{name.inspect}") + end + + # --- Dispatcher for :database_name strategy --- + + def validate_for_adapter!(name, adapter_name) + case adapter_name + when /mysql/i, /trilogy/i then validate_mysql_database_name!(name) + when /postgresql/i, /postgis/i then validate_postgresql_identifier!(name) + when /sqlite/i then validate_sqlite_path!(name) + end + end + end +end +``` + +### Tests + +`spec/unit/tenant_name_validator_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/tenant_name_validator' + +RSpec.describe(Apartment::TenantNameValidator) do + describe '.validate! common rules' do + it 'rejects nil' do + expect { described_class.validate!(nil, strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /must be a String/)) + end + + it 'rejects empty string' do + expect { described_class.validate!('', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /cannot be empty/)) + end + + it 'rejects NUL bytes' do + expect { described_class.validate!("foo\x00bar", strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /NUL byte/)) + end + + it 'rejects whitespace' do + expect { described_class.validate!("foo bar", strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /whitespace/)) + end + + it 'rejects names longer than 255 characters' do + expect { described_class.validate!('a' * 256, strategy: :database_name, adapter_name: 'sqlite3') } + .to(raise_error(Apartment::ConfigurationError, /too long.*256.*max 255/)) + end + + it 'accepts valid names' do + expect { described_class.validate!('acme', strategy: :schema) }.not_to(raise_error) + end + end + + describe 'PostgreSQL identifier rules' do + it 'rejects names longer than 63 characters' do + expect { described_class.validate!('a' * 64, strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /too long.*64.*max 63/)) + end + + it 'rejects names starting with pg_' do + expect { described_class.validate!('pg_custom', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /reserved prefix/)) + end + + it 'rejects names starting with a digit' do + expect { described_class.validate!('123abc', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /Invalid PostgreSQL identifier/)) + end + + it 'rejects names with special characters' do + expect { described_class.validate!('foo@bar', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /Invalid PostgreSQL identifier/)) + end + + it 'allows hyphens (quoted by adapters)' do + expect { described_class.validate!('my-tenant', strategy: :schema) }.not_to(raise_error) + end + + it 'allows underscores' do + expect { described_class.validate!('my_tenant', strategy: :schema) }.not_to(raise_error) + end + + it 'allows names starting with underscore' do + expect { described_class.validate!('_private', strategy: :schema) }.not_to(raise_error) + end + end + + describe 'MySQL database name rules' do + let(:opts) { { strategy: :database_name, adapter_name: 'mysql2' } } + + it 'rejects names longer than 64 characters' do + expect { described_class.validate!('a' * 65, **opts) } + .to(raise_error(Apartment::ConfigurationError, /too long.*65.*max 64/)) + end + + it 'rejects names starting with a digit' do + expect { described_class.validate!('123abc', **opts) } + .to(raise_error(Apartment::ConfigurationError, /cannot start with a digit/)) + end + + it 'rejects names ending with a period' do + expect { described_class.validate!('foo.', **opts) } + .to(raise_error(Apartment::ConfigurationError, /cannot end with a period/)) + end + + it 'rejects names with invalid characters' do + expect { described_class.validate!('foo@bar', **opts) } + .to(raise_error(Apartment::ConfigurationError, /Invalid MySQL/)) + end + + it 'allows hyphens and dollar signs' do + expect { described_class.validate!('my-tenant$1', **opts) }.not_to(raise_error) + end + + it 'applies to trilogy adapter' do + expect { described_class.validate!('foo@bar', strategy: :database_name, adapter_name: 'trilogy') } + .to(raise_error(Apartment::ConfigurationError, /Invalid MySQL/)) + end + end + + describe 'SQLite path rules' do + let(:opts) { { strategy: :database_name, adapter_name: 'sqlite3' } } + + it 'rejects path traversal' do + expect { described_class.validate!('../escape', **opts) } + .to(raise_error(Apartment::ConfigurationError, /path traversal/)) + end + + it 'rejects path separators' do + expect { described_class.validate!('dir/name', **opts) } + .to(raise_error(Apartment::ConfigurationError, /path separators/)) + end + + it 'allows normal names' do + expect { described_class.validate!('my_tenant', **opts) }.not_to(raise_error) + end + end + + describe 'unknown strategy' do + it 'applies only common validation for :shard strategy' do + expect { described_class.validate!('acme', strategy: :shard) }.not_to(raise_error) + end + end +end +``` + +- [ ] Write `lib/apartment/tenant_name_validator.rb` +- [ ] Write `spec/unit/tenant_name_validator_spec.rb` +- [ ] Run: `bundle exec rspec spec/unit/tenant_name_validator_spec.rb` +- [ ] Run: `bundle exec rubocop lib/apartment/tenant_name_validator.rb spec/unit/tenant_name_validator_spec.rb` +- [ ] Commit: `git commit -m "Add TenantNameValidator with engine-specific format rules"` + +--- + +## Task 2: Wire Validator into AbstractAdapter + ConnectionHandling + +**Files:** +- Modify: `lib/apartment/adapters/abstract_adapter.rb` +- Modify: `lib/apartment/patches/connection_handling.rb` +- Modify: `spec/unit/adapters/abstract_adapter_spec.rb` +- Modify: `spec/unit/patches/connection_handling_spec.rb` + +### Implementation + +Add `validated_connection_config` template method to `AbstractAdapter`: + +```ruby +# In AbstractAdapter, add above resolve_connection_config: + +# Template method: validates tenant name then delegates to resolve_connection_config. +# Called by ConnectionHandling — subclasses should NOT override this. +def validated_connection_config(tenant) + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: base_config['adapter'] + ) + resolve_connection_config(tenant) +end +``` + +Add validation to `create`: + +```ruby +# In AbstractAdapter#create, add before create_tenant: +def create(tenant) + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: base_config['adapter'] + ) + run_callbacks(:create) do + # ... existing code + end +end +``` + +Add `require_relative 'tenant_name_validator'` to `abstract_adapter.rb`. + +Update `ConnectionHandling#connection_pool` to call `validated_connection_config`: + +```ruby +# In connection_handling.rb, change: +# config = Apartment.adapter.resolve_connection_config(tenant) +# To: +# config = Apartment.adapter.validated_connection_config(tenant) +``` + +### Tests + +Add to `spec/unit/adapters/abstract_adapter_spec.rb`: + +```ruby +describe '#validated_connection_config' do + it 'returns the resolved config for valid tenant names' do + result = adapter.validated_connection_config('acme') + expect(result).to(eq(adapter: 'postgresql', database: 'acme')) + end + + it 'raises ConfigurationError for invalid tenant names' do + expect { adapter.validated_connection_config("bad\x00name") } + .to(raise_error(Apartment::ConfigurationError, /NUL byte/)) + end +end + +# Update existing create test: +describe '#create' do + it 'raises ConfigurationError for invalid tenant names' do + expect { adapter.create("bad\x00name") } + .to(raise_error(Apartment::ConfigurationError, /NUL byte/)) + end +end +``` + +- [ ] Add `validated_connection_config` to `AbstractAdapter` +- [ ] Add validation call to `AbstractAdapter#create` +- [ ] Update `ConnectionHandling` to call `validated_connection_config` +- [ ] Add unit tests for the new methods +- [ ] Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb spec/unit/patches/connection_handling_spec.rb` +- [ ] Run: `bundle exec rspec spec/unit/` (full unit suite) +- [ ] Run: `bundle exec rubocop` on changed files +- [ ] Commit: `git commit -m "Wire TenantNameValidator into adapter create and pool resolution"` + +--- + +## Task 3: Config additions (schema_load_strategy, schema_file) + +**Files:** +- Modify: `lib/apartment/config.rb` +- Modify: `spec/unit/config_spec.rb` + +### Implementation + +Add to `Config#initialize`: + +```ruby +@schema_load_strategy = :schema_rb # default: load db/schema.rb +@schema_file = nil # nil = auto-detect from Rails or default +``` + +Add attr_accessor: + +```ruby +attr_accessor :tenants_provider, :default_tenant, :excluded_models, + # ... existing ... + :schema_load_strategy, :schema_file +``` + +Add validation in `validate!`: + +```ruby +unless [nil, :schema_rb, :sql].include?(@schema_load_strategy) + raise(ConfigurationError, "Invalid schema_load_strategy: #{@schema_load_strategy.inspect}. " \ + 'Must be nil, :schema_rb, or :sql') +end +``` + +### Tests + +```ruby +describe 'schema_load_strategy' do + it 'defaults to :schema_rb' do + config = described_class.new + expect(config.schema_load_strategy).to eq(:schema_rb) + end + + it 'accepts nil, :schema_rb, and :sql' do + %i[schema_rb sql].each do |strategy| + config = described_class.new + config.schema_load_strategy = strategy + expect(config.schema_load_strategy).to eq(strategy) + end + end + + it 'rejects invalid values during validation' do + # Must go through full configure flow since validate! checks it + expect { + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.schema_load_strategy = :invalid + end + }.to raise_error(Apartment::ConfigurationError, /Invalid schema_load_strategy/) + end +end +``` + +- [ ] Add `schema_load_strategy` and `schema_file` to `Config` +- [ ] Add validation in `Config#validate!` +- [ ] Add unit tests +- [ ] Run: `bundle exec rspec spec/unit/config_spec.rb` +- [ ] Commit: `git commit -m "Add schema_load_strategy and schema_file config options"` + +--- + +## Task 4: Schema loading in AbstractAdapter#create + import_schema + +**Files:** +- Modify: `lib/apartment/adapters/abstract_adapter.rb` +- Modify: `spec/unit/adapters/abstract_adapter_spec.rb` + +### Implementation + +Add `import_schema` private method and update `create`: + +```ruby +def create(tenant) + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: base_config['adapter'] + ) + run_callbacks(:create) do + create_tenant(tenant) + import_schema(tenant) if Apartment.config.schema_load_strategy + seed(tenant) if Apartment.config.seed_after_create + Instrumentation.instrument(:create, tenant: tenant) + end +end + +private + +def import_schema(tenant) + Apartment::Tenant.switch(tenant) do + schema_file = resolve_schema_file + case Apartment.config.schema_load_strategy + when :schema_rb + load(schema_file) + when :sql + ActiveRecord::Tasks::DatabaseTasks.load_schema( + ActiveRecord::Base.connection_db_config, :sql, schema_file + ) + end + end +rescue StandardError => e + raise(Apartment::SchemaLoadError, + "Failed to load schema for tenant '#{tenant}': #{e.class}: #{e.message}") +end + +def resolve_schema_file + custom = Apartment.config.schema_file + return custom if custom + + if defined?(Rails) + Rails.root.join('db', 'schema.rb').to_s + else + 'db/schema.rb' + end +end +``` + +### Tests + +```ruby +describe '#create with schema loading' do + before do + reconfigure(schema_load_strategy: :schema_rb) + allow(Apartment::Instrumentation).to(receive(:instrument)) + end + + it 'calls import_schema when schema_load_strategy is set' do + expect(adapter).to(receive(:import_schema).with('acme')) + adapter.create('acme') + end + + it 'does not call import_schema when strategy is nil' do + reconfigure(schema_load_strategy: nil) + expect(adapter).not_to(receive(:import_schema)) + adapter.create('acme') + end + + it 'calls seed after schema import when seed_after_create is true' do + reconfigure(schema_load_strategy: :schema_rb, seed_after_create: true, seed_data_file: '/tmp/seeds.rb') + call_order = [] + allow(adapter).to(receive(:import_schema) { call_order << :schema }) + allow(File).to(receive(:exist?).and_return(true)) + allow(adapter).to(receive(:load) { call_order << :seed }) + adapter.create('acme') + expect(call_order).to(eq([:schema, :seed])) + end + + it 'raises SchemaLoadError when schema loading fails' do + allow(adapter).to(receive(:import_schema).and_raise( + Apartment::SchemaLoadError, "Failed to load schema for tenant 'acme': RuntimeError: boom" + )) + expect { adapter.create('acme') }.to(raise_error(Apartment::SchemaLoadError, /boom/)) + end +end + +describe '#resolve_schema_file (private)' do + it 'returns custom schema_file when configured' do + reconfigure(schema_file: '/custom/schema.rb') + expect(adapter.send(:resolve_schema_file)).to(eq('/custom/schema.rb')) + end + + it 'returns Rails.root/db/schema.rb when Rails is defined' do + # Rails is stubbed at the top of this spec file + expect(adapter.send(:resolve_schema_file)).to(include('db/schema.rb')) + end + + it 'returns db/schema.rb as fallback' do + allow(adapter).to(receive(:defined?).and_return(false)) + # In practice, without Rails defined, falls back to 'db/schema.rb' + result = adapter.send(:resolve_schema_file) + expect(result).to(end_with('schema.rb')) + end +end + +describe '#import_schema (private)' do + it 'calls load with the resolved schema file for :schema_rb strategy' do + reconfigure(schema_load_strategy: :schema_rb, schema_file: '/tmp/test_schema.rb') + # import_schema switches tenant and calls load(path) + expect(adapter).to(receive(:load).with('/tmp/test_schema.rb')) + adapter.send(:import_schema, 'acme') + end + + it 'wraps load errors in SchemaLoadError' do + reconfigure(schema_load_strategy: :schema_rb, schema_file: '/tmp/bad_schema.rb') + allow(adapter).to(receive(:load).and_raise(RuntimeError, 'syntax error')) + expect { adapter.send(:import_schema, 'acme') } + .to(raise_error(Apartment::SchemaLoadError, /syntax error/)) + end +end +``` + +- [ ] Add `import_schema` and `resolve_schema_file` to `AbstractAdapter` +- [ ] Update `create` to call `import_schema` and `seed` +- [ ] Add unit tests +- [ ] Run: `bundle exec rspec spec/unit/adapters/abstract_adapter_spec.rb` +- [ ] Run: `bundle exec rspec spec/unit/` (full suite — ensure no regressions) +- [ ] Commit: `git commit -m "Add schema loading during tenant creation via import_schema"` + +--- + +## Task 5: v4 Railtie + +**Files:** +- Replace: `lib/apartment/railtie.rb` +- Modify: `lib/apartment.rb` (remove from Zeitwerk ignore) +- Create: `lib/apartment/tasks/v4.rake` +- Create: `spec/unit/railtie_spec.rb` + +### Implementation + +`lib/apartment/railtie.rb`: + +```ruby +# frozen_string_literal: true + +require 'rails' + +module Apartment + class Railtie < Rails::Railtie + # After all initializers have run: wire up Apartment if configured. + config.after_initialize do + next unless Apartment.config + + # Warn if isolation_level is :thread — v4 needs :fiber for CurrentAttributes safety. + if defined?(ActiveSupport::IsolatedExecutionState) && + ActiveSupport::IsolatedExecutionState.isolation_level == :thread + warn '[Apartment] WARNING: ActiveSupport isolation_level is :thread. ' \ + 'Apartment v4 requires :fiber for correct CurrentAttributes propagation. ' \ + 'Set config.active_support.isolation_level = :fiber in your application config.' + end + + begin + Apartment.activate! + Apartment::Tenant.init + rescue ActiveRecord::NoDatabaseError + # Swallow: database may not exist yet (db:create compatibility). + warn '[Apartment] Database not found during init — skipping. Run db:create first.' + end + end + + # Insert elevator middleware if configured. + initializer 'apartment.middleware' do |app| + next unless Apartment.config&.elevator + + elevator_class = resolve_elevator_class(Apartment.config.elevator) + options = Apartment.config.elevator_options || {} + app.middleware.use(elevator_class, *options.values) + end + + rake_tasks do + load File.expand_path('tasks/v4.rake', __dir__) + end + + private + + def resolve_elevator_class(elevator) + class_name = "Apartment::Elevators::#{elevator.to_s.camelize}" + # Require the elevator file — elevators are not autoloaded (Zeitwerk-ignored directory). + require "apartment/elevators/#{elevator}" + class_name.constantize + rescue NameError, LoadError => e + available = Dir[File.join(__dir__, 'elevators', '*.rb')] + .map { |f| File.basename(f, '.rb') } + .reject { |n| n == 'generic' } + raise(Apartment::ConfigurationError, + "Unknown elevator '#{elevator}': #{e.message}. " \ + "Available elevators: #{available.join(', ')}") + end + end +end +``` + +Remove `railtie` from Zeitwerk ignore list in `lib/apartment.rb`: + +```ruby +# Change this: +%w[ + railtie + deprecation + ... +].each { |f| loader.ignore("#{__dir__}/apartment/#{f}.rb") } + +# To this (remove 'railtie' from the list): +%w[ + deprecation + ... +].each { |f| loader.ignore("#{__dir__}/apartment/#{f}.rb") } +``` + +`lib/apartment/tasks/v4.rake`: + +```ruby +# frozen_string_literal: true + +namespace :apartment do + desc 'Create all tenant schemas/databases from tenants_provider' + task create: :environment do + tenants = Apartment.config.tenants_provider.call + tenants.each do |tenant| + puts "Creating tenant: #{tenant}" + Apartment::Tenant.create(tenant) + rescue Apartment::TenantExists + puts " already exists, skipping" + rescue StandardError => e + warn " FAILED: #{e.message}" + end + end + + desc 'Drop a tenant schema/database' + task :drop, [:tenant] => :environment do |_t, args| + abort 'Usage: rake apartment:drop[tenant_name]' unless args[:tenant] + Apartment::Tenant.drop(args[:tenant]) + puts "Dropped tenant: #{args[:tenant]}" + end + + desc 'Run migrations for all tenants' + task migrate: :environment do + tenants = Apartment.config.tenants_provider.call + tenants.each do |tenant| + puts "Migrating tenant: #{tenant}" + Apartment::Tenant.migrate(tenant) + rescue StandardError => e + warn " FAILED: #{e.message}" + end + end + + desc 'Seed all tenants' + task seed: :environment do + tenants = Apartment.config.tenants_provider.call + tenants.each do |tenant| + puts "Seeding tenant: #{tenant}" + Apartment::Tenant.seed(tenant) + rescue StandardError => e + warn " FAILED: #{e.message}" + end + end + + desc 'Rollback migrations for all tenants' + task :rollback, [:step] => :environment do |_t, args| + step = (args[:step] || 1).to_i + tenants = Apartment.config.tenants_provider.call + tenants.each do |tenant| + puts "Rolling back tenant: #{tenant} (#{step} step(s))" + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection_pool.migration_context.rollback(step) + end + rescue StandardError => e + warn " FAILED: #{e.message}" + end + end +end +``` + +### Tests + +`spec/unit/railtie_spec.rb` — test `resolve_elevator_class` as a module-level helper. The Railtie's boot-time behavior (after_initialize, middleware insertion) is tested via the dummy app (Task 8). Unit tests only verify the elevator resolution helper. + +Note: `Rails::Railtie` doesn't expose `.instance` — we test `resolve_elevator_class` by extracting it into a testable method or calling it on a fresh Railtie instance via `.send(:new)`. The Railtie spec must guard with a `LoadError` rescue since it requires Rails. + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +# Railtie requires Rails — skip gracefully when running outside appraisal. +RAILTIE_AVAILABLE = begin + require 'apartment/railtie' + true +rescue LoadError + false +end + +RSpec.describe('Apartment::Railtie', + skip: (RAILTIE_AVAILABLE ? false : 'requires Rails')) do + # Rails::Railtie doesn't expose .instance — instantiate directly for testing. + let(:railtie) { described_class.send(:new) } + + describe 'elevator resolution' do + it 'resolves :subdomain to Apartment::Elevators::Subdomain' do + klass = railtie.send(:resolve_elevator_class, :subdomain) + expect(klass).to(eq(Apartment::Elevators::Subdomain)) + end + + it 'raises ConfigurationError for unknown elevator' do + expect { railtie.send(:resolve_elevator_class, :nonexistent) } + .to(raise_error(Apartment::ConfigurationError, /Unknown elevator/)) + end + end +end +``` + +- [ ] Replace `lib/apartment/railtie.rb` with v4 implementation +- [ ] Remove `railtie` from Zeitwerk ignore list in `lib/apartment.rb` +- [ ] Create `lib/apartment/tasks/v4.rake` +- [ ] Create `spec/unit/railtie_spec.rb` +- [ ] Run: `bundle exec rspec spec/unit/` +- [ ] Run: `bundle exec rubocop lib/apartment/railtie.rb lib/apartment/tasks/v4.rake` +- [ ] Commit: `git commit -m "Add v4 Railtie with after_initialize, middleware insertion, and rake tasks"` + +--- + +## Task 6: ConnectionHandler Swap in Integration Tests + +**Files:** +- Modify: `spec/integration/v4/support.rb` + +### Implementation + +Add an `around` hook to `support.rb` that swaps `ConnectionHandler` for every `:integration`-tagged test. This requires real ActiveRecord, so it only activates when `V4_INTEGRATION_AVAILABLE` is true. + +Add to `support.rb` after the `V4IntegrationHelper` module: + +```ruby +if V4_INTEGRATION_AVAILABLE + RSpec.configure do |config| + config.around(:each, :integration) do |example| + old_handler = ActiveRecord::Base.connection_handler + new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + ActiveRecord::Base.connection_handler = new_handler + + # Re-establish the default connection on the fresh handler so the first + # DB call in each test doesn't raise ConnectionNotEstablished. + # Individual test before blocks may re-establish with different configs. + default_config = V4IntegrationHelper.default_connection_config( + tmp_dir: @__apartment_tmp_dir # set by test's let(:tmp_dir) if present + ) + ActiveRecord::Base.establish_connection(default_config) if default_config + + example.run + ensure + # Disconnect all pools on the temporary handler before discarding. + begin + new_handler&.clear_all_connections! + rescue StandardError + nil + end + ActiveRecord::Base.connection_handler = old_handler + end + end +end +``` + +**Note:** The `establish_connection` call in the `around` hook uses the engine-appropriate default config. Individual tests that need different configs (e.g., scenario tests) re-establish in their `before` blocks, which is fine — the `around` hook provides a safe default so no test accidentally hits a bare handler. + +**Note:** Individual tests still call `establish_connection` in their `before` blocks — this is correct because each test re-establishes on the fresh handler. The `around` hook just ensures the handler itself is fresh. + +- [ ] Add `around(:each, :integration)` hook to `support.rb` +- [ ] Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/` (verify all pass with handler swap) +- [ ] Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/` +- [ ] Run: `DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/` +- [ ] Commit: `git commit -m "Add ConnectionHandler swap for hermetic integration tests"` + +--- + +## Task 7: Scenario-Based Database Configs + +**Files:** +- Create: `spec/integration/v4/scenarios/postgresql_schema.yml` +- Create: `spec/integration/v4/scenarios/postgresql_database.yml` +- Create: `spec/integration/v4/scenarios/mysql_database.yml` +- Create: `spec/integration/v4/scenarios/sqlite_file.yml` +- Modify: `spec/integration/v4/support.rb` (add scenario loading helpers) + +### Implementation + +Each YAML file defines the full scenario config: + +`spec/integration/v4/scenarios/postgresql_schema.yml`: +```yaml +name: postgresql_schema +engine: postgresql +strategy: schema +adapter_class: PostgreSQLSchemaAdapter +default_tenant: public +connection: + adapter: postgresql + host: <%= ENV.fetch('PGHOST', '127.0.0.1') %> + port: <%= ENV.fetch('PGPORT', '5432') %> + username: <%= ENV.fetch('PGUSER', ENV.fetch('USER', nil)) %> + password: <%= ENV.fetch('PGPASSWORD', nil) %> + database: <%= ENV.fetch('APARTMENT_TEST_PG_DB', 'apartment_v4_test') %> +``` + +Similar files for other scenarios with appropriate values. + +Add to `V4IntegrationHelper`: + +```ruby +Scenario = Struct.new(:name, :engine, :strategy, :adapter_class, :default_tenant, + :connection, keyword_init: true) + +def self.load_scenario(name) + path = File.join(__dir__, 'scenarios', "#{name}.yml") + raw = YAML.safe_load(ERB.new(File.read(path)).result, permitted_classes: [Symbol]) + Scenario.new(**raw.transform_keys(&:to_sym).merge( + strategy: raw['strategy'].to_sym, + connection: raw['connection'].transform_keys(&:to_s) + )) +end + +def self.scenarios_for_engine + Dir[File.join(__dir__, 'scenarios', '*.yml')].filter_map do |path| + name = File.basename(path, '.yml') + scenario = load_scenario(name) + scenario if scenario.engine == database_engine + end +end + +def self.each_scenario(&block) + scenarios_for_engine.each(&block) +end +``` + +- [ ] Create scenario YAML files +- [ ] Add `Scenario` struct and loading helpers to `support.rb` +- [ ] Add a simple test that verifies scenarios load correctly for each engine +- [ ] Run: `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/` (verify no regressions) +- [ ] Commit: `git commit -m "Add scenario-based database configs for integration tests"` + +--- + +## Task 8: Dummy App Upgrade to v4 + +**Files:** +- Modify: `spec/dummy/config/initializers/apartment.rb` +- Modify: `spec/dummy/config/application.rb` +- Modify: `spec/dummy/config/routes.rb` +- Modify: `spec/dummy/config/database.yml` +- Modify: `spec/dummy/config/environments/test.rb` +- Create: `spec/dummy/app/controllers/tenants_controller.rb` +- Create: `spec/integration/v4/request_lifecycle_spec.rb` + +### Implementation + +`spec/dummy/config/initializers/apartment.rb`: +```ruby +# frozen_string_literal: true + +Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { Company.pluck(:database) } + config.default_tenant = 'public' + config.excluded_models = ['Company'] + config.elevator = :subdomain + config.schema_load_strategy = nil # dummy app manages its own schema + config.configure_postgres do |pg| + pg.persistent_schemas = %w[public] + end +end +``` + +`spec/dummy/config/application.rb`: +```ruby +# frozen_string_literal: true + +require File.expand_path('boot', __dir__) + +require 'active_model/railtie' +require 'active_record/railtie' +require 'action_controller/railtie' + +Bundler.require +require 'apartment' + +module Dummy + class Application < Rails::Application + config.load_defaults Rails::VERSION::STRING.to_f + config.eager_load = false + config.encoding = 'utf-8' + config.filter_parameters += [:password] + + # v4 Railtie handles middleware insertion and Apartment.activate! + # No manual middleware.use needed. + end +end +``` + +`spec/dummy/config/database.yml`: +```yaml +test: + adapter: postgresql + database: apartment_postgresql_test + host: <%= ENV.fetch('PGHOST', '127.0.0.1') %> + port: <%= ENV.fetch('PGPORT', '5432') %> + username: <%= ENV.fetch('PGUSER', ENV.fetch('USER', nil)) %> + password: <%= ENV.fetch('PGPASSWORD', nil) %> + schema_search_path: public +``` + +`spec/dummy/app/controllers/tenants_controller.rb`: +```ruby +# frozen_string_literal: true + +class TenantsController < ActionController::Base + def show + render json: { + tenant: Apartment::Tenant.current, + user_count: User.count + } + end +end +``` + +`spec/dummy/config/routes.rb`: +```ruby +# frozen_string_literal: true + +Dummy::Application.routes.draw do + get '/tenant_info' => 'tenants#show' + root to: 'tenants#show' +end +``` + +`spec/integration/v4/request_lifecycle_spec.rb`: +```ruby +# frozen_string_literal: true + +# Request lifecycle tests require the dummy Rails app + real PostgreSQL. +# Run via: DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql \ +# rspec spec/integration/v4/request_lifecycle_spec.rb + +require 'spec_helper' + +DUMMY_APP_AVAILABLE = begin + require_relative '../../dummy/config/environment' + require 'rack/test' + true +rescue LoadError, StandardError => e + warn "[request_lifecycle_spec] Skipping: #{e.message}" + false +end + +RSpec.describe('v4 Request lifecycle', :request_lifecycle, + skip: (DUMMY_APP_AVAILABLE ? false : 'requires dummy Rails app + PostgreSQL')) do + include Rack::Test::Methods + + def app + Rails.application + end + + before(:all) do + # Ensure test schemas exist + %w[acme widgets].each do |tenant| + Apartment.adapter.create(tenant) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.create_table(:users, force: true) do |t| + t.string :name + end + end + rescue Apartment::TenantExists + nil + end + end + + after(:all) do + %w[acme widgets].each do |tenant| + Apartment.adapter.drop(tenant) + rescue StandardError + nil + end + end + + after do + Apartment::Current.reset + end + + it 'elevator switches tenant based on subdomain' do + header 'Host', 'acme.example.com' + get '/tenant_info' + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body['tenant']).to eq('acme') + end + + it 'data is isolated between tenants' do + Apartment::Tenant.switch('acme') { User.create!(name: 'Alice') } + + header 'Host', 'acme.example.com' + get '/tenant_info' + expect(JSON.parse(last_response.body)['user_count']).to eq(1) + + header 'Host', 'widgets.example.com' + get '/tenant_info' + expect(JSON.parse(last_response.body)['user_count']).to eq(0) + end + + it 'tenant context is cleaned up after request' do + header 'Host', 'acme.example.com' + get '/tenant_info' + # After request completes, tenant should be reset to default + expect(Apartment::Tenant.current).to eq('public') + end + + it 'returns default tenant for requests without subdomain' do + header 'Host', 'example.com' + get '/tenant_info' + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body['tenant']).to eq('public') + end +end +``` + +- [ ] Update `spec/dummy/config/initializers/apartment.rb` to v4 config +- [ ] Update `spec/dummy/config/application.rb` to remove v3 code +- [ ] Update `spec/dummy/config/database.yml` with ERB +- [ ] Update `spec/dummy/config/environments/test.rb` for modern Rails +- [ ] Update `spec/dummy/config/routes.rb` +- [ ] Create `spec/dummy/app/controllers/tenants_controller.rb` +- [ ] Create `spec/integration/v4/request_lifecycle_spec.rb` +- [ ] Add `gem 'rack-test'` to Gemfile if not present +- [ ] Run: `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/request_lifecycle_spec.rb` +- [ ] Run full integration suite: all three engines +- [ ] Commit: `git commit -m "Upgrade spec/dummy to v4, add request-lifecycle integration tests"` + +--- + +## Task 9: Coverage + TestProf Tooling + +**Files:** +- Modify: `Gemfile` +- Modify: `spec/spec_helper.rb` + +### Implementation + +Add to `Gemfile`: +```ruby +group :development, :test do + gem 'simplecov', require: false + gem 'test-prof', require: false +end +``` + +Add to top of `spec/spec_helper.rb` (must be before any other requires): +```ruby +if ENV['COVERAGE'] + require 'simplecov' + SimpleCov.start do + add_filter '/spec/' + add_filter '/gemfiles/' + add_group 'Adapters', 'lib/apartment/adapters' + add_group 'Patches', 'lib/apartment/patches' + add_group 'Config', 'lib/apartment/configs' + add_group 'Core', 'lib/apartment' + minimum_coverage 80 + end +end +``` + +- [ ] Add simplecov and test-prof to Gemfile +- [ ] Add SimpleCov configuration to spec_helper.rb +- [ ] Run: `bundle install` +- [ ] Run: `COVERAGE=1 bundle exec rspec spec/unit/` and verify coverage report +- [ ] Run: `FPROF=1 bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/` to profile +- [ ] Add `coverage/` to `.gitignore` +- [ ] Commit: `git commit -m "Add SimpleCov and TestProf for coverage and profiling"` + +--- + +## Task 10: Update CLAUDE.md and docs + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `lib/apartment/CLAUDE.md` +- Modify: `spec/CLAUDE.md` + +Update all CLAUDE.md files to reflect: +- Railtie exists (remove "v3 only" notes) +- New test commands (coverage, profiling, request lifecycle) +- New files and their purposes +- Tenant name validation as a feature + +- [ ] Update all three CLAUDE.md files +- [ ] Run: `bundle exec rspec spec/unit/` (sanity check) +- [ ] Run: all three engine integration suites +- [ ] Commit: `git commit -m "Update CLAUDE.md files for v4 Railtie and test infrastructure"` + +--- + +## Execution Order + +Tasks must be executed in order (each builds on the previous): + +1. **Task 1** — TenantNameValidator (standalone, no dependencies) +2. **Task 2** — Wire validator into adapters + ConnectionHandling +3. **Task 3** — Config additions (schema_load_strategy) +4. **Task 4** — Schema loading in create (depends on Task 3) +5. **Task 5** — v4 Railtie (depends on Tasks 1-4) +6. **Task 6** — ConnectionHandler swap (test infra, independent of 1-5 but run after to verify) +7. **Task 7** — Scenario configs (test infra, depends on Task 6) +8. **Task 8** — Dummy app upgrade (depends on Task 5 Railtie + Task 6 handler swap) +9. **Task 9** — Coverage tooling (independent, last to avoid noise during development) +10. **Task 10** — Docs (last, after all code is final) diff --git a/docs/research/connection-handling-internals.md b/docs/research/connection-handling-internals.md new file mode 100644 index 00000000..496ab02e --- /dev/null +++ b/docs/research/connection-handling-internals.md @@ -0,0 +1,181 @@ +# ActiveRecord Connection Handling Internals — Research for Phase 2.3 + +> Research notes for Apartment v4's `ConnectionHandling` patch. +> Covers AR pool resolution across Rails 7.2/8.0/8.1, prior art from other gems, and rationale for our approach. + +## ActiveRecord Pool Resolution Architecture + +### The lookup path (stable across 7.2–8.1) + +``` +ActiveRecord::Base.connection_pool + → connection_handler.retrieve_connection_pool( + connection_specification_name, + role: current_role, + shard: current_shard, + strict: true + ) + → @connection_name_to_pool_manager[connection_name] # Concurrent::Map + → PoolManager#get_pool_config(role, shard) # Hash[role][shard] + → PoolConfig#pool # the actual ConnectionPool +``` + +Pools are keyed by the tuple `(connection_name, role, shard)`. The `connection_name` is typically `"ActiveRecord::Base"` for the primary connection class. Shards are symbols. + +### `current_shard` resolution + +`current_shard` walks the `connected_to_stack` (a fiber-local array) in reverse, looking for the first entry whose `klasses` includes the current connection class. Falls back to `default_shard` (`:default`). + +This stack is what `connected_to(shard: :foo) { ... }` pushes to and pops from. + +### `establish_connection` on ConnectionHandler + +Signature (identical across 7.2/8.0/8.1): + +```ruby +def establish_connection(config, owner_name: Base, role: Base.current_role, + shard: Base.current_shard, clobber: false) +``` + +Key behaviors: +- **Idempotent**: If an existing pool has the same `db_config`, returns that pool (no duplicate creation). +- **Lazy**: In Rails 7.2+, pool is created but no connection is established until the first query. +- **Thread-safe**: `@connection_name_to_pool_manager` is a `Concurrent::Map`. `PoolManager` stores `PoolConfig` objects in a plain `Hash`, but access is synchronized by the handler. +- Accepts `owner_name` as a class or string/symbol. Strings are wrapped in an internal descriptor. + +### API differences across Rails versions + +| Aspect | 7.2 | 8.0 | 8.1 | +|--------|-----|-----|-----| +| `PoolConfig` owner field | `connection_class` | `connection_descriptor` | `connection_descriptor` | +| `ConnectionHandler` string wrapper | `StringConnectionName` | `ConnectionDescriptor` | `ConnectionDescriptor` | +| `set_pool_manager` key | `.connection_name` (string) | `.connection_descriptor` (object) | `.connection_descriptor` (object) | +| `establish_connection` public API | identical | identical | identical | +| `remove_connection_pool` public API | identical | identical | identical | +| `shard_keys` tracking | not tracked | not tracked | `@shard_keys` on connection class | +| `connected_to_all_shards` | not present | not present | iterates `shard_keys` | + +**Key takeaway**: The `connection_class` → `connection_descriptor` rename in 8.0 is internal to `PoolConfig`. The public API we use (`establish_connection`, `remove_connection_pool`, `retrieve_connection_pool`) is stable across all three versions. + +### `remove_connection_pool` — pool cleanup + +```ruby +def remove_connection_pool(connection_name, role:, shard:) + pool_manager = get_pool_manager(connection_name) + pool_config = pool_manager.remove_pool_config(role, shard) + pool_config.disconnect! # calls pool.disconnect! +end +``` + +Stable API across 7.2–8.1. This is what our PoolReaper must call during eviction. + +### `prohibit_shard_swapping` + +Rails provides `prohibit_shard_swapping` to prevent nested shard switches (e.g., in per-request database isolation). If user code calls this, and we use shard-based pool keying, our tenant switch would raise. + +**Our mitigation**: We override `connection_pool` directly rather than using `connected_to(shard:)`. Our patch reads `Apartment::Current.tenant` and calls `establish_connection` / `retrieve_connection_pool` with a specific shard key. This bypasses `connected_to_stack` entirely, so `prohibit_shard_swapping` does not affect us. + +### Rails 8.1 `shard_keys` tracking + +Rails 8.1 tracks `@shard_keys` set via `connects_to shards: { ... }`. Our dynamically created tenant shards will NOT appear in this list. `connected_to_all_shards` will not iterate our tenants. + +This is fine — Apartment has its own `tenants_provider` and `Tenant.switch` API for iterating tenants. We should document this distinction. + +--- + +## Prior Art: Other Multi-Tenancy Approaches + +### Basecamp's `activerecord-tenanted` (2025) + +**Repo**: [basecamp/activerecord-tenanted](https://github.com/basecamp/activerecord-tenanted) + +**Approach**: Extends ActiveRecord to dynamically create a `ConnectionPool` per tenant on demand, using Rails' horizontal sharding infrastructure. Each tenant is treated as a shard. The `tenanted` macro on `ApplicationRecord` enables tenant-aware connections. + +**Key design choices**: +- `with_tenant("acme") { ... }` — block-scoped tenant context (analogous to our `Tenant.switch`) +- Database path uses `%{tenant}` interpolation in `database.yml` (e.g., `storage/production/%{tenant}/main.sqlite3`) +- `max_connection_pools` config limits active tenant connections +- Integrates with Action Dispatch, Active Job, Action Cable, Turbo, Active Storage, etc. +- Currently SQLite-only; PostgreSQL support is being explored ([#194](https://github.com/basecamp/activerecord-tenanted/discussions/194), [#261](https://github.com/basecamp/activerecord-tenanted/pull/261)) + +**Relevance to Apartment v4**: Validates the "tenant as shard" approach. Their gem is designed for the ONCE/Writebook model (SQLite file-per-tenant). Apartment targets a broader range: PostgreSQL schemas, PostgreSQL databases, MySQL databases, and SQLite files — with schema-based tenancy (shared DB, isolated namespaces) being the most common production pattern. + +**Key difference**: `activerecord-tenanted` uses `database.yml` interpolation for tenant config resolution. Apartment v4 uses adapter-specific `resolve_connection_config` methods, which is more flexible for schema-based tenancy where the database is shared but the `schema_search_path` differs per tenant. + +### Julik's "A Can of Shardines" (April 2025) + +**Blog post**: [blog.julik.nl/2025/04/a-can-of-shardines](https://blog.julik.nl/2025/04/a-can-of-shardines) + +**Context**: Author struggled with Apartment's thread-safety issues (#304) and documented the journey to a correct SQLite-per-tenant solution using Rails' `connected_to` API. + +**Key insights**: +1. **`establish_connection` is not the right API for per-request switching** — it was designed for static, boot-time configuration. The correct approach is to pre-register connection pools and use `connected_to(role:)` or `connected_to(shard:)` to switch. +2. **Lazy pool registration**: Check if a pool exists for the tenant's role/shard; if not, call `establish_connection` on the `ConnectionHandler` to register it. Protect with a mutex. +3. **Streaming Rack bodies**: The `connected_to` block-based API doesn't work for streaming responses. Julik solves this with a `Fiber` that enters the `connected_to` block, yields, and resumes on `body.close`. +4. **Database servers vs. SQLite**: Database servers are optimized for few large databases; SQLite thrives with many small ones. This informs why Apartment's PostgreSQL schema strategy (one DB, many schemas) is the right pattern for server DBs. + +**Relevance to Apartment v4**: Reinforces that the pool-per-tenant approach is sound. Apartment v4's `CurrentAttributes`-based context handles the streaming body concern differently — `Current.tenant` persists across the fiber/thread boundary naturally (since `CurrentAttributes` uses `IsolatedExecutionState`), so we don't need the Fiber trick. Our `connection_pool` override reads `Current.tenant` on every pool lookup, which works regardless of whether we're in a `call` or a streaming body. + +### Discussion: #194 — MySQL and PostgreSQL support for activerecord-tenanted + +**Status (as of March 2026)**: Maintainer @flavorjones is working on isolating SQLite-specific code. PR #204 (merged) separated create/destroy logic. Draft PR #261 adds PostgreSQL support but is still WIP. + +**Key takeaway**: The `activerecord-tenanted` gem is SQLite-first by design. Adding PostgreSQL/MySQL support is non-trivial because schema-based tenancy (shared DB, different `search_path`) has different lifecycle semantics than file-based tenancy. This is exactly what Apartment has solved for over a decade. + +--- + +## Our Approach: Tenant-as-Shard with Namespaced Keys + +### Why shard-based keying + +AR's pool resolution uses `(connection_name, role, shard)`. We map tenants to shards because: +1. **Minimal patching**: We override `connection_pool` on `ActiveRecord::Base` to return a tenant-specific pool. The pool is registered via AR's standard `establish_connection` with `shard: namespaced_key`. +2. **AR compatibility**: Pools live inside AR's `ConnectionHandler`, so `database_cleaner`, `strong_migrations`, and other gems that inspect `ActiveRecord::Base.connection_pool` see the correct pool. +3. **Lazy creation**: Pools are created on first access and cached in both our `PoolManager` (for timestamps/eviction) and AR's `ConnectionHandler` (for pool lifecycle). + +### Why namespaced shard keys + +User apps may already use `connects_to shards: { ... }`. Bare tenant names (`:acme`) could collide with user-defined shards. We namespace with a configurable prefix: + +```ruby +shard_key = :"#{Apartment.config.shard_key_prefix}_#{tenant}" +# Default: :apartment_acme +``` + +The prefix is configurable via `config.shard_key_prefix` (default: `"apartment"`). + +### Data isolation guarantee + +Each tenant gets its own `ConnectionPool` with tenant-specific config baked in at creation time: +- **PostgreSQL (schema strategy)**: `schema_search_path: "acme,ext,public"` — the connection can only see tables in these schemas. +- **PostgreSQL/MySQL (database strategy)**: `database: "acme_production"` — the connection points at a different database entirely. +- **SQLite**: `database: "storage/acme.sqlite3"` — a different file. + +Because the config is **immutable per pool**, a connection checked out from tenant A's pool cannot accidentally execute queries against tenant B's data. There is no `SET search_path` at switch time — the pool's connections are pre-configured. This eliminates the class of tenant leakage bugs that motivated v4. + +### How eviction integrates with AR's handler + +When `PoolReaper` evicts a tenant pool, it must: +1. Remove from `Apartment::PoolManager` (our tracking) +2. Call `connection_handler.remove_connection_pool("ActiveRecord::Base", role: :writing, shard: namespaced_key)` — this disconnects the pool and deregisters it from AR's handler + +### Complications and mitigations + +| Concern | Mitigation | +|---------|------------| +| `prohibit_shard_swapping` blocks our shard switches | We override `connection_pool` directly, bypassing `connected_to_stack` | +| Rails 8.1 `shard_keys` won't include our tenants | We have `tenants_provider` for iteration; document the distinction | +| User-defined shards collide with tenant shards | Configurable `shard_key_prefix` (default: `"apartment"`) | +| `establish_connection` re-resolves config on each call | AR's idempotent check returns existing pool if config matches; our `PoolManager.fetch_or_create` prevents redundant calls | + +--- + +## References + +- ActiveRecord source: `activerecord/lib/active_record/connection_handling.rb` +- ActiveRecord source: `activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb` +- ActiveRecord source: `activerecord/lib/active_record/connection_adapters/pool_manager.rb` +- ActiveRecord source: `activerecord/lib/active_record/connection_adapters/pool_config.rb` +- [basecamp/activerecord-tenanted](https://github.com/basecamp/activerecord-tenanted) — GUIDE.md, discussion #194, PR #261 +- [Julik Tarkhanov, "A Can of Shardines"](https://blog.julik.nl/2025/04/a-can-of-shardines) — April 2025 +- [Rails Guides: Multiple Databases](https://guides.rubyonrails.org/active_record_multiple_databases.html) diff --git a/docs/upgrading-to-v4.md b/docs/upgrading-to-v4.md new file mode 100644 index 00000000..de73d349 --- /dev/null +++ b/docs/upgrading-to-v4.md @@ -0,0 +1,325 @@ +# Upgrading to Apartment v4 + +## Requirements + +- Ruby 3.3+ +- Rails 7.2+ +- PostgreSQL 14+ +- MySQL 8.4+ +- SQLite3 + +## What Changed and Why + +v4 replaces the thread-local tenant switching model with pool-per-tenant architecture: each tenant gets a dedicated connection pool, eliminating cross-thread tenant leakage (a persistent problem in ActionCable, Sidekiq, and fiber-based servers). Tenant context is tracked via `ActiveSupport::CurrentAttributes`, making it fiber-safe by default. Configuration is immutable after boot (`Config#freeze!` runs after `Apartment.configure`). Global models use a declarative `pin_tenant` call on each class instead of a centralized config list. + +## Breaking Changes + +### Configuration + +`config.tenant_names` has been removed. Use `config.tenants_provider` instead; it must be a callable (proc or lambda). + +`config.tenant_strategy` is now required. Supported values: `:schema` (PostgreSQL schema-per-tenant) and `:database_name` (separate database per tenant). Additional strategies (`:shard`, `:database_config`) are reserved for future use and will raise `AdapterNotFound` if configured today. + +`config.use_schemas` and `config.use_sql` have been removed. Use `tenant_strategy` for the isolation model and `schema_load_strategy` (`:schema_rb` or `:sql`) for schema loading on tenant creation. + +Config is frozen after `Apartment.configure`. No runtime mutation; attempting to change config values after boot raises `FrozenError`. + +Before/after: + +```ruby +# v3 +Apartment.configure do |config| + config.excluded_models = %w[User Company] + config.tenant_names = -> { Customer.pluck(:subdomain) } + config.use_schemas = true +end + +# v4 +Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { Customer.pluck(:subdomain) } + config.default_tenant = 'public' +end +``` + +### Tenant API + +`Apartment::Tenant.switch` now requires a block. The manual switch/reset pattern (`switch!` then `reset` in `ensure`) is replaced by block-scoped switching that guarantees cleanup: + +```ruby +# v3 +Apartment::Tenant.switch!(tenant) +# ... work ... +ensure + Apartment::Tenant.reset! + +# v4 +Apartment::Tenant.switch(tenant) do + # ... work ... +end +``` + +`switch!` still exists for console/REPL use but is discouraged in application code. + +`Apartment::Tenant.current` is unchanged between v3 and v4. + +### Models + +`config.excluded_models` is deprecated. It still works in v4 as a compatibility shim (resolved at initialization time into pinned model registrations) but will be removed in v5. + +The replacement is declarative: include `Apartment::Model` and call `pin_tenant` on each global model. + +```ruby +# v3 +Apartment.configure do |config| + config.excluded_models = %w[User Company] +end + +# v4 +class User < ApplicationRecord + include Apartment::Model + pin_tenant +end + +class Company < ApplicationRecord + include Apartment::Model + pin_tenant +end +``` + +`process_excluded_models` is deprecated; use `process_pinned_models` instead. The deprecated method still works (it delegates internally) but emits a deprecation warning. + +### Middleware + +The v4 Railtie auto-inserts elevator middleware when `config.elevator` is set. Remove any manual `config.middleware.use` or `config.middleware.insert_before` lines from your application config. + +Configure via symbol: + +```ruby +Apartment.configure do |config| + config.elevator = :subdomain + # config.elevator_options = { excluded_subdomains: %w[www] } +end +``` + +Available elevators: `:subdomain`, `:first_subdomain`, `:domain`, `:host`, `:host_hash`, `:header`, `:generic`. + +### Connection Model + +v4 uses pool-per-tenant instead of thread-local switching. Each tenant gets a dedicated `ActiveRecord::ConnectionAdapters::ConnectionPool` managed by `Apartment::PoolManager`. + +Tenant context is stored in `Apartment::Current` (an `ActiveSupport::CurrentAttributes` subclass), which is fiber-safe by default. If your app uses fibers (e.g., Falcon server), ensure your Rails config sets: + +```ruby +# config/application.rb +config.active_support.isolation_level = :fiber +``` + +The Railtie emits a boot-time warning if `isolation_level` is `:thread`. + +Key config options for pool tuning: + +| Option | Default | Description | +|--------|---------|-------------| +| `tenant_pool_size` | 5 | Connections per tenant pool | +| `pool_idle_timeout` | 300 | Seconds before idle pool eviction | +| `max_total_connections` | nil | Hard cap across all tenant pools | + +## Migration Steps + +### Step 1: Update Configuration + +Replace your `config/initializers/apartment.rb`: + +```ruby +# Before (v3) +Apartment.configure do |config| + config.excluded_models = %w[User Company] + config.tenant_names = -> { Customer.pluck(:subdomain) } + config.use_schemas = true +end + +# After (v4) +Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { Customer.pluck(:subdomain) } + config.default_tenant = 'public' + + # Optional: auto-load schema into new tenants + # config.schema_load_strategy = :schema_rb + + # Optional: elevator (replaces manual middleware insertion) + # config.elevator = :subdomain +end +``` + +For MySQL, use `config.tenant_strategy = :database_name`. + +### Step 2: Update Models + +For each model previously listed in `config.excluded_models`: + +1. Add `include Apartment::Model` to the class +2. Add `pin_tenant` below the include +3. Remove the model name from the `config.excluded_models` array + +```ruby +class User < ApplicationRecord + include Apartment::Model + pin_tenant + + # ... rest of model +end +``` + +Once all models are converted, delete the `config.excluded_models` line entirely. + +For third-party gem models you cannot modify directly, `config.excluded_models` remains available as a transitional escape hatch. + +### Step 3: Update Tenant Switching + +Find and replace manual switch/reset patterns: + +```ruby +# Before +Apartment::Tenant.switch!(tenant) +do_work +ensure + Apartment::Tenant.reset! + +# After +Apartment::Tenant.switch(tenant) do + do_work +end +``` + +`Apartment::Tenant.current` is unchanged between v3 and v4; no migration needed for code that reads the current tenant. + +`Apartment::Tenant.reset` (no bang) is available for cases where you need to return to the default tenant outside a block; `reset!` no longer exists in v4. + +### Step 4: Update Middleware + +Remove manual middleware insertion from `config/application.rb` or environment configs: + +```ruby +# Delete these lines +config.middleware.use Apartment::Elevators::Subdomain +config.middleware.insert_before ActionDispatch::Session::CookieStore, Apartment::Elevators::Subdomain +``` + +Instead, configure the elevator in your Apartment initializer: + +```ruby +Apartment.configure do |config| + config.elevator = :subdomain +end +``` + +The Railtie handles middleware insertion and ordering automatically. + +### Step 5: Update Background Jobs + +Block-scoped switching in workers: + +```ruby +# Sidekiq +class TenantJob + include Sidekiq::Worker + + def perform(tenant, data) + Apartment::Tenant.switch(tenant) do + process(data) + end + end +end + +# ActiveJob +class TenantJob < ApplicationJob + def perform(tenant, data) + Apartment::Tenant.switch(tenant) do + process(data) + end + end +end +``` + +If you have a Sidekiq middleware that wraps all jobs in a tenant switch, update it to use the block form as well. + +### Step 6: Update Tests + +Reset tenant state in your test helper: + +```ruby +RSpec.configure do |config| + config.before(:each) do + Apartment::Tenant.reset + end +end +``` + +Use block-based switching in specs: + +```ruby +it 'creates tenant-scoped records' do + Apartment::Tenant.switch('test_tenant') do + expect(Post.count).to eq(0) + end +end +``` + +### Step 7: Verify + +1. Run your full test suite +2. Check connection pool behavior under load in staging: `Apartment::Tenant.pool_stats` returns per-tenant pool metrics +3. Monitor for `FrozenError` exceptions, which indicate code attempting to mutate config after boot +4. Verify elevator middleware position with `Rails.application.middleware` (should appear before session middleware) + +## connects_to Compatibility + +**Common case:** `ApplicationRecord` using `connects_to` with roles (`:writing`, `:reading`) on the same database works correctly. Apartment's `ConnectionHandling` patch routes tenant-aware lookups through the connection pool hierarchy. + +**Separate database models:** If a model uses `connects_to` to point at an entirely separate database (e.g., a shared analytics DB), add `include Apartment::Model` and `pin_tenant` to ensure it bypasses tenant switching. + +**Read replicas:** `connected_to(role: :reading)` works correctly with pinned models; the pinned model's connection targets the default tenant regardless of the active role. + +## Troubleshooting + +### "No connection defined for tenant" + +The tenant name returned by your `tenants_provider` does not match what was created. Verify: + +```ruby +Apartment.config.tenants_provider.call +# => ["tenant_a", "tenant_b"] +``` + +Ensure these names match exactly what `Apartment::Tenant.create` received (case-sensitive). + +### Connection pool sizing + +Each tenant gets `tenant_pool_size` connections (default: 5). For apps with many tenants, set `max_total_connections` to cap total database connections: + +```ruby +Apartment.configure do |config| + config.tenant_pool_size = 3 + config.max_total_connections = 100 +end +``` + +Idle pools are evicted after `pool_idle_timeout` seconds (default: 300). The `PoolReaper` runs in the background and never evicts the default tenant's pool. + +### Thread safety + +Always use block-scoped `switch` in application code. `switch!` without a block does not guarantee cleanup on exceptions and can leak tenant context across fibers or threads. + +```ruby +# Safe +Apartment::Tenant.switch(tenant) { do_work } + +# Unsafe in production (acceptable in console) +Apartment::Tenant.switch!(tenant) +``` + +### Frozen config errors + +If you see `FrozenError: can't modify frozen Apartment::Config`, you have code that mutates config after boot. Move all configuration into the `Apartment.configure` block in your initializer. Tests that need different config values must call `Apartment.configure` again (which creates a fresh config object). diff --git a/gemfiles/rails_4_2.gemfile b/gemfiles/rails_4_2.gemfile deleted file mode 100644 index 33048072..00000000 --- a/gemfiles/rails_4_2.gemfile +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# This file was generated by Appraisal - -source 'http://rubygems.org' - -gem 'rails', '~> 4.2.0' - -group :local do - gem 'guard-rspec', '~> 4.2' - gem 'pry' -end - -platforms :ruby do - gem 'mysql2', '~> 0.4.0' - gem 'pg', '< 1.0.0' -end - -platforms :jruby do - gem 'activerecord-jdbc-adapter', '~> 1.3' - gem 'activerecord-jdbcmysql-adapter', '~> 1.3' - gem 'activerecord-jdbcpostgresql-adapter', '~> 1.3' -end - -gemspec path: '../' diff --git a/gemfiles/rails_5_0.gemfile b/gemfiles/rails_5_0.gemfile deleted file mode 100644 index 37dc042b..00000000 --- a/gemfiles/rails_5_0.gemfile +++ /dev/null @@ -1,23 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 5.0.0" -gem "rubocop" - -group :local do - gem "guard-rspec", "~> 4.2" - gem "pry" -end - -platforms :ruby do - gem "pg", "< 1.0.0" -end - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 50.0" - gem "activerecord-jdbcpostgresql-adapter", "~> 50.0" - gem "activerecord-jdbcmysql-adapter", "~> 50.0" -end - -gemspec path: "../" diff --git a/gemfiles/rails_5_1.gemfile b/gemfiles/rails_5_1.gemfile deleted file mode 100644 index 59af05f8..00000000 --- a/gemfiles/rails_5_1.gemfile +++ /dev/null @@ -1,23 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 5.1.0" -gem "rubocop" - -group :local do - gem "guard-rspec", "~> 4.2" - gem "pry" -end - -platforms :ruby do - gem "pg", "< 1.0.0" -end - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 51.0" - gem "activerecord-jdbcpostgresql-adapter", "~> 51.0" - gem "activerecord-jdbcmysql-adapter", "~> 51.0" -end - -gemspec path: "../" diff --git a/gemfiles/rails_5_2.gemfile b/gemfiles/rails_5_2.gemfile deleted file mode 100644 index 18d8952a..00000000 --- a/gemfiles/rails_5_2.gemfile +++ /dev/null @@ -1,19 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 5.2.0" -gem "rubocop" - -group :local do - gem "guard-rspec", "~> 4.2" - gem "pry" -end - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 52.0" - gem "activerecord-jdbcpostgresql-adapter", "~> 52.0" - gem "activerecord-jdbcmysql-adapter", "~> 52.0" -end - -gemspec path: "../" diff --git a/gemfiles/rails_6_0.gemfile b/gemfiles/rails_6_0.gemfile deleted file mode 100644 index 6d23e4aa..00000000 --- a/gemfiles/rails_6_0.gemfile +++ /dev/null @@ -1,23 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 6.0.0" -gem "rubocop" - -group :local do - gem "guard-rspec", "~> 4.2" - gem "pry" -end - -platforms :ruby do - gem "sqlite3", "~> 1.4" -end - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 60.0" - gem "activerecord-jdbcpostgresql-adapter", "~> 60.0" - gem "activerecord-jdbcmysql-adapter", "~> 60.0" -end - -gemspec path: "../" diff --git a/gemfiles/rails_6_1.gemfile b/gemfiles/rails_6_1.gemfile deleted file mode 100644 index e6de4f27..00000000 --- a/gemfiles/rails_6_1.gemfile +++ /dev/null @@ -1,23 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 6.1.0" -gem "rubocop" - -group :local do - gem "guard-rspec", "~> 4.2" - gem "pry" -end - -platforms :ruby do - gem "sqlite3", "~> 1.4" -end - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 61.0" - gem "activerecord-jdbcpostgresql-adapter", "~> 61.0" - gem "activerecord-jdbcmysql-adapter", "~> 61.0" -end - -gemspec path: "../" diff --git a/gemfiles/rails_7.2_mysql2.gemfile b/gemfiles/rails_7.2_mysql2.gemfile new file mode 100644 index 00000000..7214e244 --- /dev/null +++ b/gemfiles/rails_7.2_mysql2.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 7.2.0" +gem "mysql2", "~> 0.5" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_7.2_postgresql.gemfile b/gemfiles/rails_7.2_postgresql.gemfile new file mode 100644 index 00000000..e79c9d67 --- /dev/null +++ b/gemfiles/rails_7.2_postgresql.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 7.2.0" +gem "pg", "~> 1.5" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_7.2_sqlite3.gemfile b/gemfiles/rails_7.2_sqlite3.gemfile new file mode 100644 index 00000000..f55fbc94 --- /dev/null +++ b/gemfiles/rails_7.2_sqlite3.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 7.2.0" +gem "sqlite3", "~> 2.1" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_7.2_trilogy.gemfile b/gemfiles/rails_7.2_trilogy.gemfile new file mode 100644 index 00000000..d616f9a9 --- /dev/null +++ b/gemfiles/rails_7.2_trilogy.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 7.2.0" +gem "trilogy", ">= 2.9" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.0_mysql2.gemfile b/gemfiles/rails_8.0_mysql2.gemfile new file mode 100644 index 00000000..81a1f3c8 --- /dev/null +++ b/gemfiles/rails_8.0_mysql2.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.0.0" +gem "mysql2", "~> 0.5" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.0_postgresql.gemfile b/gemfiles/rails_8.0_postgresql.gemfile new file mode 100644 index 00000000..f24db476 --- /dev/null +++ b/gemfiles/rails_8.0_postgresql.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.0.0" +gem "pg", "~> 1.5" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.0_sqlite3.gemfile b/gemfiles/rails_8.0_sqlite3.gemfile new file mode 100644 index 00000000..2a71bbc3 --- /dev/null +++ b/gemfiles/rails_8.0_sqlite3.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.0.0" +gem "sqlite3", "~> 2.1" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.0_trilogy.gemfile b/gemfiles/rails_8.0_trilogy.gemfile new file mode 100644 index 00000000..eaf717dd --- /dev/null +++ b/gemfiles/rails_8.0_trilogy.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.0.0" +gem "trilogy", ">= 2.9" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.1_mysql2.gemfile b/gemfiles/rails_8.1_mysql2.gemfile new file mode 100644 index 00000000..6fb34332 --- /dev/null +++ b/gemfiles/rails_8.1_mysql2.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.1.0" +gem "mysql2", "~> 0.5" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.1_postgresql.gemfile b/gemfiles/rails_8.1_postgresql.gemfile new file mode 100644 index 00000000..de2d2a80 --- /dev/null +++ b/gemfiles/rails_8.1_postgresql.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.1.0" +gem "pg", "~> 1.6" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.1_sqlite3.gemfile b/gemfiles/rails_8.1_sqlite3.gemfile new file mode 100644 index 00000000..f344c48f --- /dev/null +++ b/gemfiles/rails_8.1_sqlite3.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.1.0" +gem "sqlite3", "~> 2.8" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_8.1_trilogy.gemfile b/gemfiles/rails_8.1_trilogy.gemfile new file mode 100644 index 00000000..18546fae --- /dev/null +++ b/gemfiles/rails_8.1_trilogy.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", "~> 8.1.0" +gem "trilogy", ">= 2.9" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_main_postgresql.gemfile b/gemfiles/rails_main_postgresql.gemfile new file mode 100644 index 00000000..66e8bd64 --- /dev/null +++ b/gemfiles/rails_main_postgresql.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", github: "rails/rails", branch: "main" +gem "pg", "~> 1.6" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_main_sqlite3.gemfile b/gemfiles/rails_main_sqlite3.gemfile new file mode 100644 index 00000000..3aea1158 --- /dev/null +++ b/gemfiles/rails_main_sqlite3.gemfile @@ -0,0 +1,19 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "~> 2.5" +gem "rspec", "~> 3.10" +gem "rails", github: "rails/rails", branch: "main" +gem "sqlite3", "~> 2.8" + +group :development do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-thread_safety", require: false +end + +gemspec path: "../" diff --git a/gemfiles/rails_master.gemfile b/gemfiles/rails_master.gemfile deleted file mode 100644 index 82ad9191..00000000 --- a/gemfiles/rails_master.gemfile +++ /dev/null @@ -1,23 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", git: "https://github.com/rails/rails.git" -gem "rubocop" - -group :local do - gem "guard-rspec", "~> 4.2" - gem "pry" -end - -platforms :ruby do - gem "sqlite3", "~> 1.4" -end - -platforms :jruby do - gem "activerecord-jdbc-adapter", "~> 52.0" - gem "activerecord-jdbcpostgresql-adapter", "~> 52.0" - gem "activerecord-jdbcmysql-adapter", "~> 52.0" -end - -gemspec path: "../" diff --git a/lib/apartment.rb b/lib/apartment.rb index a9506fd9..394edbec 100644 --- a/lib/apartment.rb +++ b/lib/apartment.rb @@ -1,159 +1,228 @@ # frozen_string_literal: true -require 'apartment/railtie' if defined?(Rails) -require 'active_support/core_ext/object/blank' -require 'forwardable' -require 'active_record' -require 'apartment/tenant' +require 'zeitwerk' +require 'active_support' +require 'active_support/current_attributes' +require 'concurrent' -require_relative 'apartment/log_subscriber' +# Set up Zeitwerk autoloader for the Apartment namespace. +# Must happen before requiring files that define constants in the Apartment module. +loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false) -if ActiveRecord.version.release >= Gem::Version.new('6.0') - require_relative 'apartment/active_record/connection_handling' -end - -if ActiveRecord.version.release >= Gem::Version.new('6.1') - require_relative 'apartment/active_record/schema_migration' - require_relative 'apartment/active_record/internal_metadata' -end - -# Apartment main definitions -module Apartment - class << self - extend Forwardable - - ACCESSOR_METHODS = %i[use_schemas use_sql seed_after_create prepend_environment default_tenant - append_environment with_multi_server_setup tenant_presence_check active_record_log].freeze - - WRITER_METHODS = %i[tenant_names database_schema_file excluded_models - persistent_schemas connection_class - db_migrate_tenants db_migrate_tenant_missing_strategy seed_data_file - parallel_migration_threads pg_excluded_names].freeze - - attr_accessor(*ACCESSOR_METHODS) - attr_writer(*WRITER_METHODS) - - if ActiveRecord.version.release >= Gem::Version.new('6.1') - def_delegators :connection_class, :connection, :connection_db_config, :establish_connection +# errors.rb defines multiple constants (not a single Errors class), +# so it must be loaded explicitly rather than autoloaded. +loader.ignore("#{__dir__}/apartment/errors.rb") - def connection_config - connection_db_config.configuration_hash - end - else - def_delegators :connection_class, :connection, :connection_config, :establish_connection - end - - # configure apartment with available options - def configure - yield self if block_given? - end +# Railtie is loaded explicitly via require_relative at the bottom of this file. +loader.ignore("#{__dir__}/apartment/railtie.rb") - def tenant_names - extract_tenant_config.keys.map(&:to_s) - end +# Rake tasks are loaded by the Railtie, not autoloaded. +loader.ignore("#{__dir__}/apartment/tasks") - def tenants_with_config - extract_tenant_config - end +# CLI is loaded explicitly (require 'apartment/cli') by rake tasks and the binstub. +# Ignoring cli.rb avoids Zeitwerk mapping it to Apartment::Cli (wrong casing). +# Ignoring cli/ avoids autoloading Thor subcommands before Thor is required. +loader.ignore("#{__dir__}/apartment/cli.rb") +loader.ignore("#{__dir__}/apartment/cli") - def tld_length=(_) - Apartment::Deprecation.warn('`config.tld_length` have no effect because it was removed in https://github.com/influitive/apartment/pull/309') - end +# Collapse concerns/ so Zeitwerk maps lib/apartment/concerns/model.rb +# to Apartment::Model (not Apartment::Concerns::Model). Mirrors the +# Rails convention for app/models/concerns/. +loader.collapse("#{__dir__}/apartment/concerns") - def db_config_for(tenant) - (tenants_with_config[tenant] || connection_config) - end +loader.setup - # Whether or not db:migrate should also migrate tenants - # defaults to true - def db_migrate_tenants - return @db_migrate_tenants if defined?(@db_migrate_tenants) +require_relative 'apartment/errors' - @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 - def db_migrate_tenant_missing_strategy - valid = %i[rescue_exception raise_exception create_tenant] - value = @db_migrate_tenant_missing_strategy || :rescue_exception - - return value if valid.include?(value) - - key_name = 'config.db_migrate_tenant_missing_strategy' - opt_names = valid.join(', ') +module Apartment + class << self + attr_reader :config, :pool_manager, :pool_reaper + attr_writer :adapter - raise ApartmentError, "Option #{value} not valid for `#{key_name}`. Use one of #{opt_names}" + # Lazy-loading adapter. Built on first access via build_adapter. + # Can be set manually (e.g., in tests) via Apartment.adapter=. + def adapter + @adapter ||= build_adapter end - # Default to empty array - def excluded_models - @excluded_models || [] + # Registry of models that declared pin_tenant. + # Uses Concurrent::Set for thread safety (Zeitwerk autoload in threaded servers). + def pinned_models + @pinned_models ||= Concurrent::Set.new end - def parallel_migration_threads - @parallel_migration_threads || 0 + def register_pinned_model(klass) + pinned_models.add(klass) end - def persistent_schemas - @persistent_schemas || [] + # Check if a class (or any of its ancestors) is a pinned model. + # Used by ConnectionHandling to skip tenant pool routing. + def pinned_model?(klass) + klass.ancestors.any? { |a| a.is_a?(Class) && pinned_models.include?(a) } end - def connection_class - @connection_class || ActiveRecord::Base + def activated? + @activated == true end - def database_schema_file - return @database_schema_file if defined?(@database_schema_file) - - @database_schema_file = Rails.root.join('db', 'schema.rb') + def process_pinned_model(klass) + adapter&.process_pinned_model(klass) end - def seed_data_file - return @seed_data_file if defined?(@seed_data_file) - - @seed_data_file = Rails.root.join('db', 'seeds.rb') + # Configure Apartment v4. Yields a Config instance, validates it, + # and prepares the module for use. + # + # Apartment.configure do |config| + # config.tenant_strategy = :schema + # config.tenants_provider = -> { Tenant.pluck(:name) } + # end + # + def configure + raise(ConfigurationError, 'Apartment.configure requires a block') unless block_given? + + new_config = Config.new + yield(new_config) + new_config.validate! + new_config.freeze! + + # Validation passed — tear down old state and swap in new. + teardown_old_state + @config = new_config + @pool_manager = PoolManager.new + @pool_reaper = PoolReaper.new( + pool_manager: @pool_manager, + interval: new_config.pool_idle_timeout, + idle_timeout: new_config.pool_idle_timeout, + max_total: new_config.max_total_connections, + default_tenant: new_config.default_tenant, + shard_key_prefix: new_config.shard_key_prefix + ) + @pool_reaper.start + @config + end + + # Reset all configuration and stop background tasks. + def clear_config + teardown_old_state + # Reset per-model processing flags so re-configuration re-establishes connections. + @pinned_models&.each do |klass| + next unless klass.instance_variable_defined?(:@apartment_connection_established) + + klass.remove_instance_variable(:@apartment_connection_established) + end + @config = nil + @pool_manager = nil + @pool_reaper = nil + @pinned_models = nil + @activated = false + end + + # Activate the ConnectionHandling patch on ActiveRecord::Base. + # Idempotent — prepend on an already-prepended module is a no-op. + def activate! + require_relative('apartment/patches/connection_handling') + ActiveRecord::Base.singleton_class.prepend(Patches::ConnectionHandling) + @activated = true + end + + # Register a :tenant tag with ActiveRecord::QueryLogs so SQL queries + # include a /* tenant='name' */ comment. No-op when sql_query_tags is + # false or ActiveRecord::QueryLogs is not available. + def activate_sql_query_tags! + return unless @config&.sql_query_tags + return unless defined?(ActiveRecord::QueryLogs) + return if ActiveRecord::QueryLogs.tags.include?(:tenant) + + ActiveRecord::QueryLogs.taggings = ActiveRecord::QueryLogs.taggings.merge( + tenant: -> { Apartment::Current.tenant } + ) + ActiveRecord::QueryLogs.tags = ActiveRecord::QueryLogs.tags + [:tenant] + end + + # Deregister a single tenant's shard from AR's ConnectionHandler. + # Safe to call when AR is not loaded or config is not set (no-op). + # Used by PoolReaper eviction, AbstractAdapter#drop, and teardown. + def deregister_shard(pool_key) + return unless @config && defined?(ActiveRecord::Base) + + _, separator, role_str = pool_key.to_s.rpartition(':') + role = separator.empty? || role_str.empty? ? ActiveRecord.writing_role : role_str.to_sym + + shard_key = :"#{@config.shard_key_prefix}_#{pool_key}" + ActiveRecord::Base.connection_handler.remove_connection_pool( + 'ActiveRecord::Base', + role: role, + shard: shard_key + ) + rescue StandardError => e + warn "[Apartment] Failed to deregister AR pool for #{pool_key}: #{e.class}: #{e.message}" + end + + private + + # Safely tear down old state. Stops the reaper first (so it doesn't + # evict mid-cleanup), then deregisters tenant pools from AR's + # ConnectionHandler, then clears the pool manager. + def teardown_old_state + begin + @pool_reaper&.stop + rescue StandardError => e + warn "[Apartment] PoolReaper.stop failed during teardown: #{e.class}: #{e.message}" + end + deregister_all_tenant_pools + @pool_manager&.clear + @adapter = nil end - def pg_excluded_names - @pg_excluded_names || [] - end + def deregister_all_tenant_pools + return unless @pool_manager - # Reset all the config for Apartment - def reset - (ACCESSOR_METHODS + WRITER_METHODS).each do |method| - remove_instance_variable(:"@#{method}") if instance_variable_defined?(:"@#{method}") + @pool_manager.stats[:tenants]&.each do |tenant_key| + deregister_shard(tenant_key) end end - def extract_tenant_config - return {} unless @tenant_names - - 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 - end - end - values.with_indifferent_access - rescue ActiveRecord::StatementInvalid - {} + # Factory: resolve the correct adapter class based on strategy and database adapter. + def build_adapter + raise(ConfigurationError, 'Apartment not configured. Call Apartment.configure first.') unless @config + + strategy = config.tenant_strategy + db_adapter = detect_database_adapter + + klass = case strategy + when :schema + require_relative('apartment/adapters/postgresql_schema_adapter') + Adapters::PostgresqlSchemaAdapter + when :database_name + case db_adapter + when /postgresql/, /postgis/ + require_relative('apartment/adapters/postgresql_database_adapter') + Adapters::PostgresqlDatabaseAdapter + when /mysql2/ + require_relative('apartment/adapters/mysql2_adapter') + Adapters::Mysql2Adapter + when /trilogy/ + require_relative('apartment/adapters/trilogy_adapter') + Adapters::TrilogyAdapter + when /sqlite/ + require_relative('apartment/adapters/sqlite3_adapter') + Adapters::Sqlite3Adapter + else + raise(AdapterNotFound, "No adapter for database: #{db_adapter}") + end + else + raise(AdapterNotFound, "Strategy #{strategy} not yet implemented") + end + + klass.new(ActiveRecord::Base.connection_db_config.configuration_hash) + end + + def detect_database_adapter + ActiveRecord::Base.connection_db_config.adapter end end - - # Exceptions - ApartmentError = Class.new(StandardError) - - # Raised when apartment cannot find the adapter specified in config/database.yml - AdapterNotFound = Class.new(ApartmentError) - - # Raised when apartment cannot find the file to be loaded - FileNotFound = Class.new(ApartmentError) - - # Tenant specified is unknown - TenantNotFound = Class.new(ApartmentError) - - # The Tenant attempting to be created already exists - TenantExists = Class.new(ApartmentError) end + +# Load Railtie when Rails is present (standard gem convention). +# Railtie is Zeitwerk-ignored — this explicit require is the only load path. +require_relative 'apartment/railtie' if defined?(Rails::Railtie) diff --git a/lib/apartment/CLAUDE.md b/lib/apartment/CLAUDE.md new file mode 100644 index 00000000..5bf1d1d4 --- /dev/null +++ b/lib/apartment/CLAUDE.md @@ -0,0 +1,106 @@ +# lib/apartment/ - Core Implementation Directory + +This directory contains v4 implementation files. v3 files have been deleted as of Phase 2.5. See `docs/designs/apartment-v4.md` for the v4 architecture. + +## Directory Structure + +``` +lib/apartment/ +├── adapters/ # Database-specific tenant isolation (see CLAUDE.md) +│ ├── abstract_adapter.rb # Base adapter: lifecycle, callbacks, resolve_connection_config, base_config +│ ├── postgresql_schema_adapter.rb # Schema-per-tenant (CREATE/DROP SCHEMA, schema_search_path) +│ ├── postgresql_database_adapter.rb # Database-per-tenant on PostgreSQL (CREATE/DROP DATABASE) +│ ├── mysql2_adapter.rb # Database-per-tenant on MySQL (mysql2 driver) +│ ├── trilogy_adapter.rb # Database-per-tenant on MySQL (trilogy driver, inherits Mysql2Adapter) +│ └── sqlite3_adapter.rb # File-per-tenant (FileUtils lifecycle) +├── concerns/ # ActiveRecord concerns for tenant-aware models +│ └── model.rb # Apartment::Model concern: pin_tenant, apartment_pinned? +├── configs/ # Database-specific config objects +│ ├── postgresql_config.rb # PostgresqlConfig: persistent_schemas, enforce_search_path_reset +│ └── mysql_config.rb # MysqlConfig: placeholder +├── elevators/ # Rack middleware for tenant detection (see CLAUDE.md); v4 uses constructor keyword args, no class-level state; Generic, Subdomain, FirstSubdomain, Domain, Host, HostHash, Header +├── patches/ # ActiveRecord patches for tenant-aware connections +│ └── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool +├── tasks/ # Rake task utilities; v4.rake for apartment:create/drop/migrate/seed/rollback +├── config.rb # Configuration with validate!/freeze! +├── current.rb # Fiber-safe tenant context (CurrentAttributes) +├── errors.rb # Exception hierarchy +├── instrumentation.rb # ActiveSupport::Notifications wrapper +├── migrator.rb # Migration orchestrator: sequential/parallel, Result/MigrationRun value objects +├── pool_manager.rb # Concurrent::Map pool cache with monotonic timestamps +├── pool_reaper.rb # Background idle/LRU pool eviction +├── railtie.rb # Rails initialization (activate!, middleware, rake tasks) +├── schema_dumper_patch.rb # Rails 8.1 schema dump fix: strips public. prefix from table names +├── tenant.rb # Public API facade (switch, current, reset, lifecycle) +├── tenant_name_validator.rb # Pure in-memory tenant name format validation +└── version.rb # Gem version constant +``` + +## v4 Files + +### tenant.rb — Public API + +`switch(tenant) { ... }` sets `Current.tenant` via ensure block. Delegates lifecycle ops (`create`, `drop`, `migrate`, `seed`) to `Apartment.adapter`. No thread-local state — uses `CurrentAttributes` for fiber safety. + +### config.rb — Configuration + +`Apartment.configure { |c| ... }` builds config, validates, freezes. Prepare-then-swap pattern: failed configure preserves previous working config. Frozen after validation — tests must reconfigure, not stub. + +### current.rb — Tenant Context + +`ActiveSupport::CurrentAttributes` subclass with `tenant` and `previous_tenant` attributes. Fiber-safe, auto-reset per request by Rails. + +### pool_manager.rb — Pool Cache + +`Concurrent::Map` storing connection pools by tenant key. Monotonic clock timestamps for idle/LRU tracking. `stats_for` returns `{ seconds_idle: N }`. `clear` disconnects all pools before clearing. + +### pool_reaper.rb — Pool Eviction + +Background `Concurrent::TimerTask` instance that evicts idle and excess tenant pools. Created by `Apartment.configure`, stored as `Apartment.pool_reaper`. Deregisters evicted pools from AR's ConnectionHandler. Default tenant is never evicted. + +### adapters/abstract_adapter.rb — Base Adapter + +Lifecycle ops (`create`, `drop`, `migrate`, `seed`), `ActiveSupport::Callbacks` on `:create`/`:switch`, `resolve_connection_config` (abstract — subclasses override), `process_excluded_models`, `environmentify`, `base_config` (stringified `connection_config`), `rails_env` (guarded `Rails.env` access). Constructor takes `connection_config` (raw AR hash, not `Apartment::Config`). + +### Concrete Adapters (Phase 2.2) + +All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `create_tenant`, `drop_tenant`. + +- **PostgresqlSchemaAdapter** — `schema_search_path` with persistent schemas. Does NOT environmentify (schemas are named directly). `CREATE/DROP SCHEMA IF EXISTS ... CASCADE`. +- **PostgresqlDatabaseAdapter** — `database` key with environmentified name. `CREATE/DROP DATABASE IF EXISTS`. +- **Mysql2Adapter** — Same pattern as PostgresqlDatabaseAdapter. `CREATE/DROP DATABASE IF EXISTS`. +- **TrilogyAdapter** — Empty subclass of Mysql2Adapter (alternative MySQL driver). +- **Sqlite3Adapter** — `database` key with file path. `FileUtils.mkdir_p` for create, `FileUtils.rm_f` for drop. + +### concerns/model.rb — Model Pinning Concern + +`Apartment::Model` provides `pin_tenant` (class method) to declare a model as pinned to the default tenant. Registered models bypass the `ConnectionHandling` patch. Zeitwerk-safe: works whether called before or after `activate!`. `apartment_pinned?` checks the class and its superclass chain. + +### railtie.rb — v4 Rails Integration + +Three hooks in Rails boot order: +1. `config.after_initialize` — Guards on `Apartment.config.nil?`, warns if isolation_level is `:thread`, calls `activate!` and `Tenant.init` +2. `initializer 'apartment.middleware'` — Inserts elevator if `config.elevator` set, resolves via `resolve_elevator_class` (symbols, strings, or classes), passes `elevator_options` as keyword args, emits boot-time trust warning for Header elevator without `trusted: true` +3. `rake_tasks` — Loads `tasks/v4.rake` (apartment:create, :drop, :migrate, :seed, :rollback) + +### migrator.rb — Migration Orchestrator + +`Apartment::Migrator` runs migrations across all tenants with optional thread-based parallelism. Delegates to `Apartment::Tenant.switch` for each tenant — the `ConnectionHandling` patch routes `AR::Base.connection_pool` to the tenant's pool, so Rails' migration machinery (which hardcodes `AR::Base.lease_connection`) uses the correct connection automatically. No standalone pools or handler swaps. Disables PG advisory locks for tenant migrations (database-wide locks serialize parallel execution; see issue #298). `Result` (Data.define) tracks per-tenant success/failure/skip. `MigrationRun` aggregates results with `#success?`, `#summary`. Primary migration aborts the run on failure (tenants are never touched). Constructor accepts `threads:` (0=sequential). RBAC credential separation (`migration_db_config`) is deferred to Phase 5. + +### schema_dumper_patch.rb — Rails 8.1 Schema Fix + +Patches `ActiveRecord::SchemaDumper` to strip `public.` prefix from table names in `schema.rb` output. Applied conditionally for Rails 8.1+ via `SchemaDumperPatch.apply!` (called by Railtie). Respects `PostgresqlConfig#include_schemas_in_dump` for non-public schemas that should retain their prefix. + +### tenant_name_validator.rb — Name Validation + +Pure module, no IO. `validate!(name, strategy:, adapter_name:)` checks common rules (non-empty, no NUL, no whitespace, max 255) then engine-specific: PG identifiers (max 63, no `pg_` prefix), MySQL names (max 64, no leading digit), SQLite paths (no traversal). + +## Data Flow + +**Tenant creation**: `Tenant.create` → `adapter.create` → `TenantNameValidator.validate!` → callbacks → `create_tenant` (subclass) → `import_schema` (if configured) → instrumentation + +**Tenant switching (v4)**: `Tenant.switch` → `Current.tenant =` → yield → ensure restore. No SQL switching — connection pool resolved by `ConnectionHandling` patch (Phase 2.3). + +**Migration flow**: `Migrator#run` → Phase 1: migrate primary (default tenant) → Phase 2: migrate tenants (sequential or parallel via threads) → Phase 3: schema dump → return `MigrationRun` + +**Request flow**: HTTP → Elevator middleware → `Tenant.switch` → app processes → ensure cleanup diff --git a/lib/apartment/active_record/connection_handling.rb b/lib/apartment/active_record/connection_handling.rb deleted file mode 100644 index f306258e..00000000 --- a/lib/apartment/active_record/connection_handling.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - # This is monkeypatching activerecord to ensure that whenever a new connection is established it - # switches to the same tenant as before the connection switching. This problem is more evident when - # using read replica in Rails 6 - module ConnectionHandling - def connected_to_with_tenant(database: nil, role: nil, prevent_writes: false, &blk) - current_tenant = Apartment::Tenant.current - - connected_to_without_tenant(database: database, role: role, prevent_writes: prevent_writes) do - Apartment::Tenant.switch!(current_tenant) - yield(blk) - end - end - - alias connected_to_without_tenant connected_to - alias connected_to connected_to_with_tenant - end -end diff --git a/lib/apartment/active_record/internal_metadata.rb b/lib/apartment/active_record/internal_metadata.rb deleted file mode 100644 index 4febd0cf..00000000 --- a/lib/apartment/active_record/internal_metadata.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class InternalMetadata < ActiveRecord::Base # :nodoc: - class << self - def table_exists? - connection.table_exists?(table_name) - end - end -end diff --git a/lib/apartment/active_record/schema_migration.rb b/lib/apartment/active_record/schema_migration.rb deleted file mode 100644 index cbad3648..00000000 --- a/lib/apartment/active_record/schema_migration.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - class SchemaMigration < ActiveRecord::Base # :nodoc: - class << self - def table_exists? - connection.table_exists?(table_name) - end - end - end -end diff --git a/lib/apartment/adapters/CLAUDE.md b/lib/apartment/adapters/CLAUDE.md new file mode 100644 index 00000000..e8d79759 --- /dev/null +++ b/lib/apartment/adapters/CLAUDE.md @@ -0,0 +1,187 @@ +# lib/apartment/adapters/ - Database Adapter Implementations + +> **Note**: v4 adapters (Phase 2.2+): `abstract_adapter.rb` (base class with lifecycle, callbacks, `base_config`, `rails_env` guard), `postgresql_schema_adapter.rb`, `postgresql_database_adapter.rb`, `mysql2_adapter.rb`, `trilogy_adapter.rb`, `sqlite3_adapter.rb`. v4 adapters handle lifecycle only (create/drop/resolve_connection_config) — switching is handled by `CurrentAttributes` + pool lookup (Phase 2.3). JDBC and PostGIS adapters are dropped in v4. See `docs/designs/apartment-v4.md` for v4 architecture. + +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_schema_adapter.rb # PostgreSQL schema-based isolation +├── postgresql_database_adapter.rb # PostgreSQL database-based isolation +├── 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 +``` + +## Adapter Hierarchy + +``` +AbstractAdapter +├── PostgresqlSchemaAdapter +├── PostgresqlDatabaseAdapter +├── Mysql2Adapter +│ └── TrilogyAdapter (alternative MySQL driver) +└── 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 Adapters + +### PostgresqlSchemaAdapter + +**Location**: `postgresql_schema_adapter.rb` + +Uses **PostgreSQL schemas** (namespaces) for tenant isolation. Does NOT environmentify tenant names — schemas are named directly. Resolves connection config via `schema_search_path`. `CREATE/DROP SCHEMA IF EXISTS ... CASCADE`. + +Persistent schemas (configured via `PostgresqlConfig#persistent_schemas`) stay in `search_path` across all tenants — use for shared extensions (uuid-ossp, hstore) or reference data. + +**Performance**: <1ms switching (no connection change), ~50MB total, 100+ tenants. + +### PostgresqlDatabaseAdapter + +**Location**: `postgresql_database_adapter.rb` + +Uses **separate databases** on PostgreSQL. Environmentifies tenant names. `CREATE/DROP DATABASE IF EXISTS`. + +## MySQL Adapters + +**Locations**: `mysql2_adapter.rb`, `trilogy_adapter.rb` + +Uses **separate databases** for each tenant. `CREATE/DROP DATABASE IF EXISTS`. Environmentifies tenant names. + +`TrilogyAdapter` is an empty subclass of `Mysql2Adapter` using the `trilogy` gem. Auto-selected when `adapter: trilogy` in `database.yml`. + +**Performance**: 10-50ms switching (connection establishment), ~20MB per active tenant, 10-50 concurrent tenants. Database-level isolation. + +## SQLite Adapter + +**Location**: `sqlite3_adapter.rb` + +Uses **separate database files** for each tenant. `database` key with file path. `FileUtils.mkdir_p` for create, `FileUtils.rm_f` for drop. + +Best for testing and development. Not suitable for concurrent multi-user production use. ~5MB per file, complete isolation. + +## Adapter Selection Matrix + +| Adapter | Database Type | Strategy | Speed | Scalability | Isolation | Best For | +|------------------------------|---------------|--------------|--------------|-------------|-----------|-------------------------| +| PostgresqlSchemaAdapter | PostgreSQL | Schemas | Very Fast | Excellent | Good | 100+ tenants | +| PostgresqlDatabaseAdapter | PostgreSQL | Databases | Moderate | Good | Excellent | Complete PG isolation | +| 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 | + +## 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_schema_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 specs in `spec/unit/` (unit) and `spec/integration/v4/` (integration against real databases). + +## 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 + +`PostgresqlSchemaAdapter` uses a shared connection pool (schema_search_path switching). Configure pool size in `database.yml`. + +### MySQL / PostgresqlDatabaseAdapter: Pool-per-Tenant + +v4 uses a `PoolManager` (LRU cache with `PoolReaper`) to limit memory usage with many tenants. Idle pools are evicted automatically. See `pool_manager.rb` and `pool_reaper.rb`. + +## 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 e59ba523..35484797 100644 --- a/lib/apartment/adapters/abstract_adapter.rb +++ b/lib/apartment/adapters/abstract_adapter.rb @@ -1,274 +1,234 @@ # frozen_string_literal: true +require 'active_support/callbacks' +require 'active_support/core_ext/string/inflections' +require_relative '../tenant_name_validator' + module Apartment module Adapters - # Abstract adapter from which all the Apartment DB related adapters will inherit the base logic - class AbstractAdapter + class AbstractAdapter # rubocop:disable Metrics/ClassLength include ActiveSupport::Callbacks - define_callbacks :create, :switch - - attr_writer :default_tenant - - # @constructor - # @param {Hash} config Database config - # - def initialize(config) - @config = config - end - - # Create a new tenant, import schema, seed if appropriate - # - # @param {String} tenant Tenant name - # - def create(tenant) - run_callbacks :create do - create_tenant(tenant) - switch(tenant) do - import_database_schema + define_callbacks :create, :switch - # Seed data if appropriate - seed_data if Apartment.seed_after_create + # The raw database connection configuration hash (from ActiveRecord). + # Not to be confused with Apartment.config (the Apartment::Config object). + attr_reader :connection_config - yield if block_given? - end - end + def initialize(connection_config) + @connection_config = connection_config end - # Initialize Apartment config options such as excluded_models - # - def init - process_excluded_models + # Template method: validates tenant name then delegates to resolve_connection_config. + # Called by ConnectionHandling — subclasses should NOT override this. + # base_config_override: when supplied (e.g. a role-specific config from ConnectionHandling), + # the adapter builds the tenant config on top of it instead of its own base_config. + def validated_connection_config(tenant, base_config_override: nil) + effective_base = base_config_override || base_config + TenantNameValidator.validate!( + tenant, + strategy: Apartment.config.tenant_strategy, + adapter_name: effective_base['adapter'] + ) + resolve_connection_config(tenant, base_config: effective_base) end - # Note alias_method here doesn't work with inheritence apparently ?? - # - def current - Apartment.connection.current_database - end - - # Return the original public tenant - # - # @return {String} default tenant name - # - def default_tenant - @default_tenant || Apartment.default_tenant + # Resolve a tenant-specific connection config hash. + # Subclasses override to set strategy-specific keys. + def resolve_connection_config(tenant, base_config: nil) + raise(NotImplementedError) end - # Drop the tenant - # - # @param {String} tenant name - # - def drop(tenant) - with_neutral_connection(tenant) do |conn| - drop_command(conn, tenant) + # Create a new tenant (schema or database). + def create(tenant) + TenantNameValidator.validate!( + environmentify(tenant), + strategy: Apartment.config.tenant_strategy, + adapter_name: base_config['adapter'] + ) + run_callbacks(:create) do + create_tenant(tenant) + grant_tenant_privileges(tenant) + import_schema(tenant) if Apartment.config.schema_load_strategy + seed(tenant) if Apartment.config.seed_after_create + Instrumentation.instrument(:create, tenant: tenant) end - rescue *rescuable_exceptions => e - raise_drop_tenant_error!(tenant, e) end - # Switch to a new tenant - # - # @param {String} tenant name - # - def switch!(tenant = nil) - run_callbacks :switch do - connect_to_new(tenant).tap do - Apartment.connection.clear_query_cache + # Drop a tenant. + def drop(tenant) # rubocop:disable Metrics/CyclomaticComplexity + drop_tenant(tenant) + removed_pools = Apartment.pool_manager&.remove_tenant(tenant) || [] + removed_pools.each do |pool_key, pool| + begin + pool&.disconnect! if pool.respond_to?(:disconnect!) + rescue StandardError => e + warn "[Apartment] Pool disconnect failed for '#{pool_key}': #{e.class}: #{e.message}" + end + begin + deregister_shard_from_ar_handler(pool_key) + rescue StandardError => e + warn "[Apartment] Shard deregistration failed for '#{pool_key}': #{e.class}: #{e.message}" end end + Instrumentation.instrument(:drop, tenant: tenant) end - # Connect to tenant, do your biz, switch back to previous tenant - # - # @param {String?} tenant to connect to - # - def switch(tenant = nil) - previous_tenant = current - switch!(tenant) - yield - ensure - begin - switch!(previous_tenant) - rescue StandardError => _e - reset - end - end - - # Iterate over all tenants, switch to tenant and yield tenant name - # - def each(tenants = Apartment.tenant_names) - tenants.each do |tenant| - switch(tenant) { yield tenant } - end - end - - # Establish a new connection for each specific excluded model - # - def process_excluded_models - # All other models will shared a connection (at Apartment.connection_class) - # and we can modify at will - Apartment.excluded_models.each do |excluded_model| - process_excluded_model(excluded_model) + # Run migrations for a tenant. + def migrate(tenant, version = nil) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection_pool.migration_context.migrate(version) end end - # Reset the tenant connection to the default - # - def reset - Apartment.establish_connection @config - end + # Run seeds for a tenant. + def seed(tenant) + Apartment::Tenant.switch(tenant) do + seed_file = Apartment.config.seed_data_file + return unless seed_file - # Load the rails seed file into the db - # - def seed_data - # Don't log the output of seeding the db - silence_warnings { load_or_raise(Apartment.seed_data_file) } if Apartment.seed_data_file - end - alias seed seed_data - - # Prepend the environment if configured and the environment isn't already there - # - # @param {String} tenant Database name - # @return {String} tenant name with Rails environment *optionally* prepended - # - def environmentify(tenant) - return tenant if tenant.nil? || tenant.include?(Rails.env) + unless File.exist?(seed_file) + raise(Apartment::ConfigurationError, + "Seed file '#{seed_file}' does not exist") + end - if Apartment.prepend_environment - "#{Rails.env}_#{tenant}" - elsif Apartment.append_environment - "#{tenant}_#{Rails.env}" - else - tenant + load(seed_file) end end - protected - - def process_excluded_model(excluded_model) - excluded_model.constantize.establish_connection @config - end - - def drop_command(conn, tenant) - # connection.drop_database note that drop_database will not throw an exception, so manually execute - conn.execute("DROP DATABASE #{conn.quote_table_name(environmentify(tenant))}") - end + # Process all pinned models — establish separate connections pinned to default tenant. + def process_pinned_models + return if Apartment.pinned_models.empty? - # Create the tenant - # - # @param {String} tenant Database name - # - def create_tenant(tenant) - with_neutral_connection(tenant) do |conn| - create_tenant_command(conn, tenant) + Apartment.pinned_models.each do |klass| + process_pinned_model(klass) end - rescue *rescuable_exceptions => e - raise_create_tenant_error!(tenant, e) - end - - def create_tenant_command(conn, tenant) - conn.create_database(environmentify(tenant), @config) end - # Connect to new tenant - # - # @param {String} tenant Database name - # - def connect_to_new(tenant) - return reset if tenant.nil? + # Process a single pinned model. Called by process_pinned_models (batch) + # and by Apartment::Model.pin_tenant (when activated? is true). + def process_pinned_model(klass) + # Idempotent: skip if already processed. Uses a class-level flag rather + # than connection_specification_name comparison — the spec name differs + # from ActiveRecord::Base for ApplicationRecord subclasses even before + # establish_connection, so it's not a reliable "already processed" signal. + return if klass.instance_variable_get(:@apartment_connection_established) - query_cache_enabled = ActiveRecord::Base.connection.query_cache_enabled + # Use base_config (the adapter's raw connection config) rather than + # resolve_connection_config(default_tenant). For database-per-tenant + # strategies (MySQL, SQLite), resolve_connection_config would set the + # database key to the default tenant NAME (e.g. 'default'), not the + # actual default database (e.g. 'apartment_v4_test'). base_config + # points to the real default database. + klass.establish_connection(base_config) + klass.instance_variable_set(:@apartment_connection_established, true) - Apartment.establish_connection multi_tenantify(tenant) - Apartment.connection.active? # call active? to manually check if this connection is valid + return unless Apartment.config.tenant_strategy == :schema - Apartment.connection.enable_query_cache! if query_cache_enabled - rescue *rescuable_exceptions => e - Apartment::Tenant.reset if reset_on_connection_exception? - raise_connect_error!(tenant, e) + table = klass.table_name.split('.').last + klass.table_name = "#{default_tenant}.#{table}" end - # Import the database schema - # - def import_database_schema - ActiveRecord::Schema.verbose = false # do not log schema load output. - - load_or_raise(Apartment.database_schema_file) if Apartment.database_schema_file + # Deprecated: use process_pinned_models instead. + def process_excluded_models + warn '[Apartment] DEPRECATION: process_excluded_models is deprecated. ' \ + 'Use Apartment::Model with pin_tenant instead.' + process_pinned_models end - # Return a new config that is multi-tenanted - # @param {String} tenant: Database name - # @param {Boolean} with_database: if true, use the actual tenant's db name - # if false, use the default db name from the db - # rubocop:disable Style/OptionalBooleanParameter - def multi_tenantify(tenant, with_database = true) - db_connection_config(tenant).tap do |config| - multi_tenantify_with_tenant_db_name(config, tenant) if with_database + # Environmentify a tenant name based on config. + # :prepend/:append require Rails to be defined (for Rails.env). + def environmentify(tenant) + case Apartment.config.environmentify_strategy + when :prepend + "#{rails_env}_#{tenant}" + when :append + "#{tenant}_#{rails_env}" + when nil + tenant.to_s + else + # Callable + Apartment.config.environmentify_strategy.call(tenant) end end - # rubocop:enable Style/OptionalBooleanParameter - def multi_tenantify_with_tenant_db_name(config, tenant) - config[:database] = environmentify(tenant) + # Default tenant from config. + def default_tenant + Apartment.config.default_tenant end - # 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) + protected - load(file) + def create_tenant(tenant) + raise(NotImplementedError) end - # Backward compatibility - alias load_or_abort load_or_raise - # Exceptions to rescue from on db operations - # - def rescuable_exceptions - [ActiveRecord::ActiveRecordError] + Array(rescue_from) + def drop_tenant(tenant) + raise(NotImplementedError) end - # Extra exceptions to rescue from - # - def rescue_from - [] - end + private - def db_connection_config(tenant) - Apartment.db_config_for(tenant).dup - end + def grant_tenant_privileges(tenant) + app_role = Apartment.config.app_role + return unless app_role - def with_neutral_connection(tenant, &_block) - 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. - SeparateDbConnectionHandler.establish_connection(multi_tenantify(tenant, false)) - yield(SeparateDbConnectionHandler.connection) - SeparateDbConnectionHandler.connection.close + conn = ActiveRecord::Base.connection + if app_role.respond_to?(:call) + app_role.call(tenant, conn) else - yield(Apartment.connection) + grant_privileges(tenant, conn, app_role) end end - def reset_on_connection_exception? - false + # No-op base implementation — PG schema and MySQL adapters override. + def grant_privileges(tenant, connection, role_name) + # intentional no-op end - def raise_drop_tenant_error!(tenant, exception) - raise TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{exception.message}" + # Connection config with string keys (used by subclasses to build tenant configs). + def base_config + connection_config.transform_keys(&:to_s) end - def raise_create_tenant_error!(tenant, exception) - raise TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{exception.message}" + def rails_env + unless defined?(Rails) + raise(Apartment::ConfigurationError, + 'environmentify_strategy :prepend/:append requires Rails to be defined') + end + Rails.env end - def raise_connect_error!(tenant, exception) - raise TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{exception.message}" + def deregister_shard_from_ar_handler(pool_key) + Apartment.deregister_shard(pool_key) end - class SeparateDbConnectionHandler < ::ActiveRecord::Base + def import_schema(tenant) + Apartment::Tenant.switch(tenant) do + schema_file = resolve_schema_file + case Apartment.config.schema_load_strategy + when :schema_rb + load(schema_file) + when :sql + ActiveRecord::Tasks::DatabaseTasks.load_schema( + ActiveRecord::Base.connection_db_config, :sql, schema_file + ) + end + end + rescue StandardError => e + raise(Apartment::SchemaLoadError, + "Failed to load schema for tenant '#{tenant}': #{e.class}: #{e.message}") + end + + def resolve_schema_file + custom = Apartment.config.schema_file + return custom if custom + + if defined?(Rails) && Rails.respond_to?(:root) && Rails.root + Rails.root.join('db/schema.rb').to_s + else + 'db/schema.rb' + end end end end diff --git a/lib/apartment/adapters/abstract_jdbc_adapter.rb b/lib/apartment/adapters/abstract_jdbc_adapter.rb deleted file mode 100644 index 4dd0748c..00000000 --- a/lib/apartment/adapters/abstract_jdbc_adapter.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'apartment/adapters/abstract_adapter' - -module Apartment - module Adapters - # JDBC Abstract adapter - class AbstractJDBCAdapter < AbstractAdapter - private - - def multi_tenantify_with_tenant_db_name(config, tenant) - config[:url] = "#{config[:url].gsub(%r{(\S+)/.+$}, '\1')}/#{environmentify(tenant)}" - end - - def rescue_from - ActiveRecord::JDBCError - end - end - end -end diff --git a/lib/apartment/adapters/jdbc_mysql_adapter.rb b/lib/apartment/adapters/jdbc_mysql_adapter.rb deleted file mode 100644 index 90c1bfeb..00000000 --- a/lib/apartment/adapters/jdbc_mysql_adapter.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'apartment/adapters/abstract_jdbc_adapter' - -module Apartment - module Tenant - def self.jdbc_mysql_adapter(config) - Adapters::JDBCMysqlAdapter.new config - end - end - - module Adapters - class JDBCMysqlAdapter < AbstractJDBCAdapter - def reset_on_connection_exception? - true - end - end - end -end diff --git a/lib/apartment/adapters/jdbc_postgresql_adapter.rb b/lib/apartment/adapters/jdbc_postgresql_adapter.rb deleted file mode 100644 index 70dbadf3..00000000 --- a/lib/apartment/adapters/jdbc_postgresql_adapter.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require 'apartment/adapters/postgresql_adapter' - -module Apartment - # JDBC helper to decide wether to use JDBC Postgresql Adapter or JDBC Postgresql Adapter with Schemas - module Tenant - def self.jdbc_postgresql_adapter(config) - if Apartment.use_schemas - Adapters::JDBCPostgresqlSchemaAdapter.new(config) - else - Adapters::JDBCPostgresqlAdapter.new(config) - end - end - end - - module Adapters - # Default adapter when not using Postgresql Schemas - class JDBCPostgresqlAdapter < PostgresqlAdapter - private - - def multi_tenantify_with_tenant_db_name(config, tenant) - config[:url] = "#{config[:url].gsub(%r{(\S+)/.+$}, '\1')}/#{environmentify(tenant)}" - end - - def create_tenant_command(conn, tenant) - conn.create_database(environmentify(tenant), thisisahack: '') - end - - def rescue_from - ActiveRecord::JDBCError - end - end - - # Separate Adapter for Postgresql when using schemas - class JDBCPostgresqlSchemaAdapter < PostgresqlSchemaAdapter - # Set schema search path to new schema - # - def connect_to_new(tenant = nil) - return reset if tenant.nil? - - tenant = tenant.to_s - raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless tenant_exists?(tenant) - - @current = tenant - 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}" - end - - private - - def tenant_exists?(tenant) - return true unless Apartment.tenant_presence_check - - Apartment.connection.all_schemas.include? tenant - end - - def rescue_from - ActiveRecord::JDBCError - end - end - end -end diff --git a/lib/apartment/adapters/mysql2_adapter.rb b/lib/apartment/adapters/mysql2_adapter.rb index ca9b1a7f..aabe2f71 100644 --- a/lib/apartment/adapters/mysql2_adapter.rb +++ b/lib/apartment/adapters/mysql2_adapter.rb @@ -1,76 +1,42 @@ # frozen_string_literal: true -require 'apartment/adapters/abstract_adapter' +require_relative 'abstract_adapter' module Apartment - # Helper module to decide wether to use mysql2 adapter or mysql2 adapter with schemas - module Tenant - def self.mysql2_adapter(config) - if Apartment.use_schemas - Adapters::Mysql2SchemaAdapter.new(config) - else - Adapters::Mysql2Adapter.new(config) - end - end - end - module Adapters - # Mysql2 Adapter + # v4 MySQL adapter using database-per-tenant isolation (mysql2 driver). + # + # Resolves tenant-specific connection configs by setting the `database` key + # to the environmentified tenant name. Lifecycle operations (create/drop) + # execute DDL against the default connection. class Mysql2Adapter < AbstractAdapter - def initialize(config) - super - - @default_tenant = config[:database] + def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + config.merge('database' => environmentify(tenant)) end protected - def rescue_from - Mysql2::Error + def create_tenant(tenant) + db_name = environmentify(tenant) + conn = ActiveRecord::Base.connection + conn.execute("CREATE DATABASE IF NOT EXISTS #{conn.quote_table_name(db_name)}") end - end - - # Mysql2 Schemas Adapter - class Mysql2SchemaAdapter < AbstractAdapter - def initialize(config) - super - @default_tenant = config[:database] - reset + def drop_tenant(tenant) + db_name = environmentify(tenant) + conn = ActiveRecord::Base.connection + conn.execute("DROP DATABASE IF EXISTS #{conn.quote_table_name(db_name)}") end - # Reset current tenant to the default_tenant - # - def reset - return unless default_tenant - - Apartment.connection.execute "use `#{default_tenant}`" - end - - protected - - # Connect to new tenant - # - def connect_to_new(tenant) - return reset if tenant.nil? - - Apartment.connection.execute "use `#{environmentify(tenant)}`" - rescue ActiveRecord::StatementInvalid => e - Apartment::Tenant.reset - raise_connect_error!(tenant, e) - end - - def process_excluded_model(model) - model.constantize.tap do |klass| - # Ensure that if a schema *was* set, we override - table_name = klass.table_name.split('.', 2).last - - klass.table_name = "#{default_tenant}.#{table_name}" - end - end + private - def reset_on_connection_exception? - true + def grant_privileges(tenant, connection, role_name) + db_name = environmentify(tenant) + quoted_role = connection.quote(role_name) + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON #{connection.quote_table_name(db_name)}.* TO #{quoted_role}@'%'" + ) end end end diff --git a/lib/apartment/adapters/postgis_adapter.rb b/lib/apartment/adapters/postgis_adapter.rb deleted file mode 100644 index bcf67be1..00000000 --- a/lib/apartment/adapters/postgis_adapter.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -# handle postgis adapter as if it were postgresql, -# only override the adapter_method used for initialization -require 'apartment/adapters/postgresql_adapter' - -module Apartment - module Tenant - def self.postgis_adapter(config) - postgresql_adapter(config) - end - end -end diff --git a/lib/apartment/adapters/postgresql_adapter.rb b/lib/apartment/adapters/postgresql_adapter.rb deleted file mode 100644 index 9540f016..00000000 --- a/lib/apartment/adapters/postgresql_adapter.rb +++ /dev/null @@ -1,291 +0,0 @@ -# frozen_string_literal: true - -require 'apartment/adapters/abstract_adapter' - -module Apartment - module Tenant - def self.postgresql_adapter(config) - adapter = Adapters::PostgresqlAdapter - adapter = Adapters::PostgresqlSchemaAdapter if Apartment.use_schemas - adapter = Adapters::PostgresqlSchemaFromSqlAdapter if Apartment.use_sql && Apartment.use_schemas - adapter.new(config) - end - end - - module Adapters - # Default adapter when not using Postgresql Schemas - class PostgresqlAdapter < AbstractAdapter - private - - def rescue_from - PG::Error - end - end - - # Separate Adapter for Postgresql when using schemas - class PostgresqlSchemaAdapter < AbstractAdapter - def initialize(config) - super - - reset - end - - def default_tenant - @default_tenant = Apartment.default_tenant || 'public' - end - - # Reset schema search path to the default schema_search_path - # - # @return {String} default schema search path - # - def reset - @current = default_tenant - Apartment.connection.schema_search_path = full_search_path - reset_sequence_names - end - - def init - super - Apartment.connection.schema_search_path = full_search_path - end - - def current - @current || default_tenant - end - - protected - - def process_excluded_model(excluded_model) - excluded_model.constantize.tap do |klass| - # Ensure that if a schema *was* set, we override - table_name = klass.table_name.split('.', 2).last - - klass.table_name = "#{default_tenant}.#{table_name}" - end - end - - def drop_command(conn, tenant) - conn.execute(%(DROP SCHEMA "#{tenant}" CASCADE)) - end - - # Set schema search path to new schema - # - def connect_to_new(tenant = nil) - return reset if tenant.nil? - - tenant = tenant.to_s - raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless tenant_exists?(tenant) - - @current = tenant - Apartment.connection.schema_search_path = full_search_path - - # When the PostgreSQL version is < 9.3, - # there is a issue for prepared statement with changing search_path. - # https://www.postgresql.org/docs/9.3/static/sql-prepare.html - Apartment.connection.clear_cache! if postgresql_version < 90_300 - reset_sequence_names - rescue *rescuable_exceptions - raise TenantNotFound, "One of the following schema(s) is invalid: \"#{tenant}\" #{full_search_path}" - end - - private - - def tenant_exists?(tenant) - return true unless Apartment.tenant_presence_check - - Apartment.connection.schema_exists?(tenant) - end - - def create_tenant_command(conn, tenant) - # NOTE: This was causing some tests to fail because of the database strategy for rspec - if ActiveRecord::Base.connection.open_transactions.positive? - conn.execute(%(CREATE SCHEMA "#{tenant}")) - else - schema = %(BEGIN; - CREATE SCHEMA "#{tenant}"; - COMMIT;) - - conn.execute(schema) - end - rescue *rescuable_exceptions => e - rollback_transaction(conn) - raise e - end - - def rollback_transaction(conn) - conn.execute('ROLLBACK;') - end - - # Generate the final search path to set including persistent_schemas - # - def full_search_path - persistent_schemas.map(&:inspect).join(', ') - end - - def persistent_schemas - [@current, Apartment.persistent_schemas].flatten - end - - def postgresql_version - # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#postgresql_version is - # public from Rails 5.0. - Apartment.connection.send(:postgresql_version) - end - - def reset_sequence_names - # sequence_name contains the schema, so it must be reset after switch - # There is `reset_sequence_name`, but that method actually goes to the database - # to find out the new name. Therefore, we do this hack to only unset the name, - # and it will be dynamically found the next time it is needed - descendants_to_unset = ActiveRecord::Base.descendants - .select { |c| c.instance_variable_defined?(:@sequence_name) } - .reject do |c| - c.instance_variable_defined?(:@explicit_sequence_name) && - c.instance_variable_get(:@explicit_sequence_name) - end - descendants_to_unset.each do |c| - # NOTE: due to this https://github.com/rails-on-services/apartment/issues/81 - # unreproduceable error we're checking before trying to remove it - c.remove_instance_variable :@sequence_name if c.instance_variable_defined?(:@sequence_name) - end - end - end - - # Another Adapter for Postgresql when using schemas and SQL - class PostgresqlSchemaFromSqlAdapter < PostgresqlSchemaAdapter - PSQL_DUMP_BLACKLISTED_STATEMENTS = [ - /SET search_path/i, # overridden later - /SET lock_timeout/i, # new in postgresql 9.3 - /SET row_security/i, # new in postgresql 9.5 - /SET idle_in_transaction_session_timeout/i, # new in postgresql 9.6 - /CREATE SCHEMA public/i, - /COMMENT ON SCHEMA public/i - - ].freeze - - def import_database_schema - preserving_search_path do - clone_pg_schema - copy_schema_migrations - end - end - - 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 - # - def preserving_search_path - search_path = Apartment.connection.execute('show search_path').first['search_path'] - yield - Apartment.connection.execute("set search_path = #{search_path}") - end - - # Clone default schema into new schema named after current tenant - # - def clone_pg_schema - pg_schema_sql = patch_search_path(pg_dump_schema) - Apartment.connection.execute(pg_schema_sql) - end - - # Copy data from schema_migrations into new schema - # - def copy_schema_migrations - pg_migrations_data = patch_search_path(pg_dump_schema_migrations_data) - Apartment.connection.execute(pg_migrations_data) - end - - # Dump postgres default schema - # - # @return {String} raw SQL contaning only postgres schema dump - # - def pg_dump_schema - # Skip excluded tables? :/ - # excluded_tables = - # collect_table_names(Apartment.excluded_models) - # .map! {|t| "-T #{t}"} - # .join(' ') - - # `pg_dump -s -x -O -n #{default_tenant} #{excluded_tables} #{dbname}` - - with_pg_env { `pg_dump -s -x -O -n #{default_tenant} #{dbname}` } - end - - # Dump data from schema_migrations table - # - # @return {String} raw SQL contaning inserts with data from schema_migrations - # - # rubocop:disable Layout/LineLength - def pg_dump_schema_migrations_data - with_pg_env { `pg_dump -a --inserts -t #{default_tenant}.schema_migrations -t #{default_tenant}.ar_internal_metadata #{dbname}` } - end - # rubocop:enable Layout/LineLength - - # Temporary set Postgresql related environment variables if there are in @config - # - def with_pg_env(&block) - pghost = ENV['PGHOST'] - pgport = ENV['PGPORT'] - pguser = ENV['PGUSER'] - pgpassword = ENV['PGPASSWORD'] - - ENV['PGHOST'] = @config[:host] if @config[:host] - ENV['PGPORT'] = @config[:port].to_s if @config[:port] - ENV['PGUSER'] = @config[:username].to_s if @config[:username] - ENV['PGPASSWORD'] = @config[:password].to_s if @config[:password] - - block.call - ensure - ENV['PGHOST'] = pghost - ENV['PGPORT'] = pgport - ENV['PGUSER'] = pguser - ENV['PGPASSWORD'] = pgpassword - end - - # Remove "SET search_path ..." line from SQL dump and prepend search_path set to current tenant - # - # @return {String} patched raw SQL dump - # - def patch_search_path(sql) - search_path = "SET search_path = \"#{current}\", #{default_tenant};" - - swap_schema_qualifier(sql) - .split("\n") - .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? } - .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 - else - match.gsub("#{default_tenant}.", %("#{current}".)) - end - end - end - - # Checks if any of regexps matches against input - # - def check_input_against_regexps(input, regexps) - regexps.select { |c| input.match c } - end - - # Collect table names from AR Models - # - def collect_table_names(models) - models.map do |m| - m.constantize.table_name - end - end - - # Convenience method for current database name - # - def dbname - Apartment.connection_config[:database] - end - end - end -end diff --git a/lib/apartment/adapters/postgresql_database_adapter.rb b/lib/apartment/adapters/postgresql_database_adapter.rb new file mode 100644 index 00000000..65a3f8ec --- /dev/null +++ b/lib/apartment/adapters/postgresql_database_adapter.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative 'abstract_adapter' + +module Apartment + module Adapters + # v4 PostgreSQL adapter using database-per-tenant isolation. + # + # Resolves tenant-specific connection configs by setting the `database` key + # to the environmentified tenant name. Lifecycle operations (create/drop) + # execute DDL against the default connection. + class PostgresqlDatabaseAdapter < AbstractAdapter + def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + config.merge('database' => environmentify(tenant)) + end + + protected + + def create_tenant(tenant) + db_name = environmentify(tenant) + conn = ActiveRecord::Base.connection + conn.execute( + "CREATE DATABASE #{conn.quote_table_name(db_name)}" + ) + rescue ActiveRecord::StatementInvalid => e + raise unless e.cause.is_a?(PG::DuplicateDatabase) + + raise(Apartment::TenantExists, tenant) + end + + def drop_tenant(tenant) + db_name = environmentify(tenant) + conn = ActiveRecord::Base.connection + conn.execute( + "DROP DATABASE IF EXISTS #{conn.quote_table_name(db_name)}" + ) + end + + # grant_privileges: inherits no-op from AbstractAdapter. + # Database-per-tenant RBAC grants require cross-database ordering + # (GRANT CONNECT on server, table grants inside tenant DB). + # Use the callable app_role escape hatch for this strategy. + # See docs/designs/v4-phase5-rbac-roles-schema-cache.md. + end + end +end diff --git a/lib/apartment/adapters/postgresql_schema_adapter.rb b/lib/apartment/adapters/postgresql_schema_adapter.rb new file mode 100644 index 00000000..1ffe87da --- /dev/null +++ b/lib/apartment/adapters/postgresql_schema_adapter.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative 'abstract_adapter' + +module Apartment + module Adapters + # v4 PostgreSQL adapter using schema-based tenant isolation. + # + # Resolves tenant-specific connection configs by setting `schema_search_path` + # to the raw tenant name (not environmentified — schemas are named directly, + # unlike database-per-tenant adapters) plus any persistent schemas from + # Apartment.config.postgres_config. Lifecycle operations (create/drop) + # execute DDL against the default connection. + class PostgresqlSchemaAdapter < AbstractAdapter + def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + persistent = Apartment.config.postgres_config&.persistent_schemas || [] + search_path = [tenant, *persistent].join(',') + + config.merge('schema_search_path' => search_path) + end + + protected + + def create_tenant(tenant) + conn = ActiveRecord::Base.connection + conn.execute("CREATE SCHEMA IF NOT EXISTS #{conn.quote_table_name(tenant)}") + end + + def drop_tenant(tenant) + conn = ActiveRecord::Base.connection + conn.execute("DROP SCHEMA IF EXISTS #{conn.quote_table_name(tenant)} CASCADE") + end + + private + + def grant_privileges(tenant, connection, role_name) # rubocop:disable Metrics/MethodLength + quoted_schema = connection.quote_table_name(tenant) + quoted_role = connection.quote_table_name(role_name) + + connection.execute("GRANT USAGE ON SCHEMA #{quoted_schema} TO #{quoted_role}") + connection.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA #{quoted_schema} TO #{quoted_role}" + ) + connection.execute( + "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA #{quoted_schema} TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT USAGE, SELECT ON SEQUENCES TO #{quoted_role}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA #{quoted_schema} " \ + "GRANT EXECUTE ON FUNCTIONS TO #{quoted_role}" + ) + end + end + end +end diff --git a/lib/apartment/adapters/sqlite3_adapter.rb b/lib/apartment/adapters/sqlite3_adapter.rb index bfa6f3de..db6df497 100644 --- a/lib/apartment/adapters/sqlite3_adapter.rb +++ b/lib/apartment/adapters/sqlite3_adapter.rb @@ -1,65 +1,39 @@ # frozen_string_literal: true -require 'apartment/adapters/abstract_adapter' +require 'fileutils' +require_relative 'abstract_adapter' module Apartment - module Tenant - def self.sqlite3_adapter(config) - Adapters::Sqlite3Adapter.new(config) - end - end - module Adapters + # v4 SQLite3 adapter using file-per-tenant isolation. + # + # Resolves tenant-specific connection configs by constructing a database + # file path from the base config's directory and the environmentified + # tenant name. SQLite creates the file on first connection, so create_tenant + # only ensures the directory exists. class Sqlite3Adapter < AbstractAdapter - def initialize(config) - @default_dir = File.expand_path(File.dirname(config[:database])) - - super - end - - def drop(tenant) - unless File.exist?(database_file(tenant)) - raise TenantNotFound, - "The tenant #{environmentify(tenant)} cannot be found." - end - - File.delete(database_file(tenant)) - end - - def current - File.basename(Apartment.connection.instance_variable_get(:@config)[:database], '.sqlite3') + def resolve_connection_config(tenant, base_config: nil) + config = base_config || send(:base_config) + db_dir = config['database'] ? File.dirname(config['database']) : 'db' + config.merge('database' => File.join(db_dir, "#{environmentify(tenant)}.sqlite3")) end protected - 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." - end - - super database_file(tenant) - end - def create_tenant(tenant) - if File.exist?(database_file(tenant)) - raise TenantExists, - "The tenant #{environmentify(tenant)} already exists." - end + # SQLite creates the file on first connection — just ensure the directory exists. + FileUtils.mkdir_p(File.dirname(database_file(tenant))) + end - begin - f = File.new(database_file(tenant), File::CREAT) - ensure - f.close - end + def drop_tenant(tenant) + FileUtils.rm_f(database_file(tenant)) end private def database_file(tenant) - "#{@default_dir}/#{environmentify(tenant)}.sqlite3" + db_dir = base_config['database'] ? File.dirname(base_config['database']) : 'db' + File.join(db_dir, "#{environmentify(tenant)}.sqlite3") end end end diff --git a/lib/apartment/adapters/trilogy_adapter.rb b/lib/apartment/adapters/trilogy_adapter.rb new file mode 100644 index 00000000..e8eccbcb --- /dev/null +++ b/lib/apartment/adapters/trilogy_adapter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative 'mysql2_adapter' + +module Apartment + module Adapters + class TrilogyAdapter < Mysql2Adapter + # Same behavior as Mysql2Adapter — Trilogy is a compatible MySQL driver. + # Exception handling differences (Trilogy::Error vs Mysql2::Error) + # are handled at the connection pool level, not the adapter. + end + end +end diff --git a/lib/apartment/cli.rb b/lib/apartment/cli.rb new file mode 100644 index 00000000..239cc475 --- /dev/null +++ b/lib/apartment/cli.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'thor' +require_relative 'cli/tenants' +require_relative 'cli/migrations' +require_relative 'cli/seeds' +require_relative 'cli/pool' + +module Apartment + class CLI < Thor + def self.exit_on_failure? = true + + register CLI::Tenants, 'tenants', 'tenants COMMAND', 'Tenant lifecycle commands' + register CLI::Migrations, 'migrations', 'migrations COMMAND', 'Migration commands' + register CLI::Seeds, 'seeds', 'seeds COMMAND', 'Seed commands' + register CLI::Pool, 'pool', 'pool COMMAND', 'Connection pool commands' + end +end diff --git a/lib/apartment/cli/migrations.rb b/lib/apartment/cli/migrations.rb new file mode 100644 index 00000000..753828c5 --- /dev/null +++ b/lib/apartment/cli/migrations.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Migrations < Thor + def self.exit_on_failure? = true + + desc 'migrate [TENANT]', 'Run migrations for tenants' + long_desc <<~DESC + Without arguments, migrates all tenants (primary DB first, then tenants + from tenants_provider). With a TENANT argument, migrates only that tenant. + + Uses Apartment::Migrator for both paths, preserving RBAC role wrapping, + advisory lock management, and instrumentation. Single-tenant mode + migrates only the named tenant (not the primary/default schema). + DESC + # Thor :numeric handles large integers (e.g. 20260401000000 timestamps) correctly. + method_option :version, type: :numeric, desc: 'Target migration version (also reads ENV VERSION)' + method_option :threads, type: :numeric, desc: 'Override parallel_migration_threads from config' + def migrate(tenant = nil) + require('apartment/migrator') + + if tenant + migrate_single(tenant) + else + migrate_all + end + end + + desc 'rollback [TENANT]', 'Rollback migrations for tenants' + long_desc <<~DESC + Without arguments, rolls back all tenants sequentially. + With a TENANT argument, rolls back only that tenant. + DESC + method_option :step, type: :numeric, default: 1, desc: 'Number of steps to rollback' + def rollback(tenant = nil) + if tenant + rollback_single(tenant) + else + rollback_all + end + end + + private + + def migrate_single(tenant) + migrator = Apartment::Migrator.new(version: resolve_version) + result = migrator.migrate_one(tenant) + if result.status == :failed + raise(Thor::Error, "Migration failed for #{tenant}: #{result.error&.class}: #{result.error&.message}") + end + + say("Migrated tenant: #{tenant} (#{result.status}, #{result.duration.round(2)}s)") + end + + def migrate_all + threads = options[:threads] || Apartment.config.parallel_migration_threads + migrator = Apartment::Migrator.new(threads: threads, version: resolve_version) + result = migrator.run + say(result.summary) + + trigger_schema_dump if result.success? + raise(Thor::Error, "Migration failed for #{result.failed.size} tenant(s)") unless result.success? + end + + # Rollback bypasses the Migrator's parallelism and Result tracking but + # respects migration_role for RBAC (rollback is DDL, same as migrate). + def rollback_single(tenant) + step = options[:step] + say("Rolling back tenant: #{tenant} (#{step} step(s))") + with_migration_role do + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection_pool.migration_context.rollback(step) + end + end + say(' done') + end + + def rollback_all + tenants = Apartment.config.tenants_provider.call + failed = [] + tenants.each do |t| + rollback_single(t) + rescue StandardError => e + warn(" FAILED: #{e.message}") + failed << t + end + return if failed.empty? + + raise(Thor::Error, "Rollback failed for #{failed.size} tenant(s): #{failed.join(', ')}") + end + + def with_migration_role(&) + Apartment::Migrator.with_migration_role(&) + end + + def resolve_version + v = options[:version] || ENV['VERSION']&.to_i + v&.zero? ? nil : v + end + + def trigger_schema_dump + return unless defined?(ActiveRecord) && ActiveRecord.dump_schema_after_migration + return unless defined?(Rake::Task) && Rake::Task.task_defined?('db:schema:dump') + + Rake::Task['db:schema:dump'].reenable + Rake::Task['db:schema:dump'].invoke + end + end + end +end diff --git a/lib/apartment/cli/pool.rb b/lib/apartment/cli/pool.rb new file mode 100644 index 00000000..0e3e49f9 --- /dev/null +++ b/lib/apartment/cli/pool.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Pool < Thor + def self.exit_on_failure? = true + + desc 'stats', 'Show connection pool statistics' + long_desc <<~DESC + Displays pool summary: total pools and tenant list. + With --verbose, shows per-tenant idle time. + DESC + method_option :verbose, type: :boolean, desc: 'Per-tenant breakdown' + def stats + unless Apartment.pool_manager + say('Apartment is not configured. Run Apartment.configure first.') + return + end + + pool_stats = Apartment.pool_manager.stats + say("Total pools: #{pool_stats[:total_pools]}") + print_tenant_details(pool_stats[:tenants]) + end + + desc 'evict', 'Force idle pool eviction' + long_desc <<~DESC + Triggers one synchronous eviction cycle (idle + LRU). + Requires confirmation unless --force is set. + DESC + method_option :force, type: :boolean, desc: 'Skip confirmation prompt' + def evict + unless Apartment.pool_reaper + say('Apartment is not configured. Run Apartment.configure first.') + return + end + + return say('Cancelled.') if !force? && !yes?('Run pool eviction cycle? [y/N]') + + count = Apartment.pool_reaper.run_cycle + say("Evicted #{count} pool(s).") + end + + private + + def print_tenant_details(tenants) + return unless tenants&.any? + + if options[:verbose] + say("\nPer-tenant details:") + tenants.each do |tenant_key| + tenant_stats = Apartment.pool_manager.stats_for(tenant_key) + idle = tenant_stats ? "#{tenant_stats[:seconds_idle].round(1)}s idle" : 'unknown' + say(" #{tenant_key}: #{idle}") + end + else + say("Tenants: #{tenants.join(', ')}") + end + end + + def force? + options[:force] || ENV['APARTMENT_FORCE'] == '1' + end + end + end +end diff --git a/lib/apartment/cli/seeds.rb b/lib/apartment/cli/seeds.rb new file mode 100644 index 00000000..b4ed7128 --- /dev/null +++ b/lib/apartment/cli/seeds.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Seeds < Thor + def self.exit_on_failure? = true + + desc 'seed [TENANT]', 'Seed tenant databases' + long_desc <<~DESC + Without arguments, seeds all tenants from tenants_provider. + With a TENANT argument, seeds only that tenant. + DESC + def seed(tenant = nil) + if tenant + seed_single(tenant) + else + seed_all + end + end + + private + + def seed_single(tenant) + say("Seeding tenant: #{tenant}") + Apartment::Tenant.seed(tenant) + say(' done') + end + + def seed_all + tenants = Apartment.config.tenants_provider.call + failed = [] + tenants.each do |t| + say("Seeding tenant: #{t}") + Apartment::Tenant.seed(t) + say(' done') + rescue StandardError => e + warn(" FAILED: #{e.message}") + failed << t + end + return if failed.empty? + + raise(Thor::Error, "Seed failed for #{failed.size} tenant(s): #{failed.join(', ')}") + end + end + end +end diff --git a/lib/apartment/cli/tenants.rb b/lib/apartment/cli/tenants.rb new file mode 100644 index 00000000..5f43d62f --- /dev/null +++ b/lib/apartment/cli/tenants.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'thor' + +module Apartment + class CLI < Thor + class Tenants < Thor + def self.exit_on_failure? = true + + desc 'create [TENANT]', 'Create tenant schema/database' + long_desc <<~DESC + Without arguments, creates all tenants returned by tenants_provider. + With a TENANT argument, creates only that tenant. + Skips tenants that already exist (no error). + DESC + method_option :quiet, type: :boolean, desc: 'Suppress per-tenant output' + def create(tenant = nil) + if tenant + create_single(tenant) + else + create_all + end + end + + desc 'drop TENANT', 'Drop a tenant schema/database' + long_desc <<~DESC + Drops the specified tenant. Requires confirmation unless --force is set. + There is no "drop all" — this is intentionally a single-tenant operation. + DESC + method_option :force, type: :boolean, desc: 'Skip confirmation prompt' + def drop(tenant) + return say('Cancelled.') if !force? && !yes?("Drop tenant '#{tenant}'? This cannot be undone. [y/N]") + + Apartment::Tenant.drop(tenant) + say("Dropped tenant: #{tenant}") unless quiet? + end + + desc 'list', 'List all tenants' + def list + Apartment.config.tenants_provider.call.each { |t| say(t) } + end + + desc 'current', 'Show current tenant' + def current + say(Apartment::Current.tenant || Apartment.config&.default_tenant || 'none') + end + + private + + def create_single(tenant) + say("Creating tenant: #{tenant}") unless quiet? + Apartment::Tenant.create(tenant) + say(' created') unless quiet? + rescue Apartment::TenantExists + say(' already exists, skipping') unless quiet? + end + + def create_all + tenants = Apartment.config.tenants_provider.call + failed = [] + tenants.each do |t| + say("Creating tenant: #{t}") unless quiet? + Apartment::Tenant.create(t) + say(' created') unless quiet? + rescue Apartment::TenantExists + say(' already exists, skipping') unless quiet? + rescue StandardError => e + warn(" FAILED: #{e.message}") + failed << t + end + return if failed.empty? + + raise(Thor::Error, "apartment tenants create failed for #{failed.size} tenant(s): #{failed.join(', ')}") + end + + def force? + options[:force] || ENV['APARTMENT_FORCE'] == '1' + end + + def quiet? + options[:quiet] || ENV['APARTMENT_QUIET'] == '1' + end + end + end +end diff --git a/lib/apartment/concerns/model.rb b/lib/apartment/concerns/model.rb new file mode 100644 index 00000000..0a2338d3 --- /dev/null +++ b/lib/apartment/concerns/model.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'active_support/concern' + +module Apartment + module Model + extend ActiveSupport::Concern + + class_methods do + # Declare this model as pinned to the default tenant. + # Pinned models bypass tenant switching in ConnectionHandling — + # their connection always targets the default tenant's database/schema. + # + # Safe to call before or after Apartment.activate!. + # Idempotent: no-op if this class (or a parent) is already pinned. + def pin_tenant + return if apartment_pinned? + + @apartment_pinned = true # rubocop:disable ThreadSafety/ClassInstanceVariable + Apartment.register_pinned_model(self) + + # If Apartment is already activated, process immediately (Zeitwerk autoload path). + # Otherwise, activate! will process all registered models. + Apartment.process_pinned_model(self) if Apartment.activated? + end + + def apartment_pinned? + return true if @apartment_pinned == true # rubocop:disable ThreadSafety/ClassInstanceVariable + return false unless superclass.respond_to?(:apartment_pinned?) + + superclass.apartment_pinned? + end + end + end +end diff --git a/lib/apartment/config.rb b/lib/apartment/config.rb new file mode 100644 index 00000000..a92c88c3 --- /dev/null +++ b/lib/apartment/config.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require_relative 'errors' +require_relative 'configs/postgresql_config' +require_relative 'configs/mysql_config' + +module Apartment + # Configuration object for Apartment v4. + # Created via Apartment.configure block; validated after the block yields. + class Config + VALID_STRATEGIES = %i[schema database_name shard database_config].freeze + VALID_ENVIRONMENTIFY_STRATEGIES = [nil, :prepend, :append].freeze + + attr_reader :tenant_strategy, :postgres_config, :mysql_config, + :environmentify_strategy, :excluded_models + + attr_accessor :tenants_provider, :default_tenant, + :tenant_pool_size, :pool_idle_timeout, :max_total_connections, + :seed_after_create, :seed_data_file, + :schema_load_strategy, :schema_file, + :parallel_migration_threads, + :elevator, :elevator_options, + :tenant_not_found_handler, :active_record_log, :sql_query_tags, + :shard_key_prefix, + :migration_role, :app_role, :schema_cache_per_tenant, :check_pending_migrations + + def initialize # rubocop:disable Metrics/AbcSize + @tenant_strategy = nil + @tenants_provider = nil + @default_tenant = nil + @excluded_models = [] + @tenant_pool_size = 5 + @pool_idle_timeout = 300 + @max_total_connections = nil + @seed_after_create = false + @seed_data_file = nil + @schema_load_strategy = nil + @schema_file = nil + @parallel_migration_threads = 0 + @environmentify_strategy = nil + @elevator = nil + @elevator_options = {} + @tenant_not_found_handler = nil + @active_record_log = false + @sql_query_tags = false + @postgres_config = nil + @mysql_config = nil + @shard_key_prefix = 'apartment' + @migration_role = nil + @app_role = nil + @schema_cache_per_tenant = false + @check_pending_migrations = true + end + + def excluded_models=(list) + unless list.empty? + warn '[Apartment] DEPRECATION: config.excluded_models is deprecated and will be ' \ + "removed in v5. Use `include Apartment::Model` and `pin_tenant` in each model instead.\n" \ + 'For third-party gem models, use config.excluded_models as a transitional escape hatch.' + end + @excluded_models = list + end + + def tenant_strategy=(strategy) + unless VALID_STRATEGIES.include?(strategy) + raise(ConfigurationError, "Invalid tenant_strategy: #{strategy.inspect}. " \ + "Must be one of: #{VALID_STRATEGIES.join(', ')}") + end + + @tenant_strategy = strategy + end + + def environmentify_strategy=(strategy) + unless VALID_ENVIRONMENTIFY_STRATEGIES.include?(strategy) || strategy.respond_to?(:call) + raise(ConfigurationError, "Invalid environmentify_strategy: #{strategy.inspect}. " \ + 'Must be nil, :prepend, :append, or a callable') + end + + @environmentify_strategy = strategy + end + + # Configure PostgreSQL-specific options via block. + def configure_postgres + @postgres_config = Configs::PostgresqlConfig.new + yield(@postgres_config) if block_given? + @postgres_config + end + + # Configure MySQL-specific options via block. + def configure_mysql + @mysql_config = Configs::MysqlConfig.new + yield(@mysql_config) if block_given? + @mysql_config + end + + # Deep-freeze the config after validation to prevent post-boot mutation. + # Freezes mutable collections and sub-configs, then freezes self. + def freeze! + @excluded_models.freeze + @elevator_options.freeze + @postgres_config&.freeze! + @mysql_config&.freeze! + # schema_file is a simple string, no deep freeze needed + @app_role.freeze if @app_role.is_a?(String) + freeze + end + + # Validate configuration completeness and consistency. + # Raises ConfigurationError on invalid state. + def validate! # rubocop:disable Metrics/AbcSize + raise(ConfigurationError, 'tenant_strategy is required') unless @tenant_strategy + + unless @tenants_provider.respond_to?(:call) + raise(ConfigurationError, 'tenants_provider must be a callable (e.g., -> { Tenant.pluck(:name) })') + end + + if @postgres_config && @mysql_config + raise(ConfigurationError, 'Cannot configure both Postgres and MySQL at the same time') + end + + unless @tenant_pool_size.is_a?(Integer) && @tenant_pool_size.positive? + raise(ConfigurationError, "tenant_pool_size must be a positive integer, got: #{@tenant_pool_size.inspect}") + end + + unless @pool_idle_timeout.is_a?(Numeric) && @pool_idle_timeout.positive? + raise(ConfigurationError, "pool_idle_timeout must be a positive number, got: #{@pool_idle_timeout.inspect}") + end + + if @max_total_connections && (!@max_total_connections.is_a?(Integer) || @max_total_connections < 1) + raise(ConfigurationError, + "max_total_connections must be a positive integer or nil, got: #{@max_total_connections.inspect}") + end + + unless [nil, :schema_rb, :sql].include?(@schema_load_strategy) + raise(ConfigurationError, "Invalid schema_load_strategy: #{@schema_load_strategy.inspect}. " \ + 'Must be nil, :schema_rb, or :sql') + end + + if @migration_role && !@migration_role.is_a?(Symbol) + raise(ConfigurationError, "migration_role must be nil or a Symbol, got: #{@migration_role.inspect}") + end + + if @app_role && !@app_role.is_a?(String) && !@app_role.respond_to?(:call) + raise(ConfigurationError, "app_role must be nil, a String, or a callable, got: #{@app_role.inspect}") + end + + unless [true, false].include?(@schema_cache_per_tenant) + raise(ConfigurationError, + "schema_cache_per_tenant must be true or false, got: #{@schema_cache_per_tenant.inspect}") + end + + unless [true, false].include?(@check_pending_migrations) + raise(ConfigurationError, + "check_pending_migrations must be true or false, got: #{@check_pending_migrations.inspect}") + end + + return if @shard_key_prefix.is_a?(String) && @shard_key_prefix.match?(/\A[a-z_][a-z0-9_]*\z/) + + raise(ConfigurationError, + 'shard_key_prefix must be a lowercase string matching /[a-z_][a-z0-9_]*/, ' \ + "got: #{@shard_key_prefix.inspect}") + end + + # Returns the current Rails environment name, falling back to env vars and a safe default. + def rails_env_name + (Rails.env if defined?(Rails.env)) || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'default_env' + end + end +end diff --git a/lib/apartment/configs/mysql_config.rb b/lib/apartment/configs/mysql_config.rb new file mode 100644 index 00000000..b39c4bc4 --- /dev/null +++ b/lib/apartment/configs/mysql_config.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Apartment + module Configs + # MySQL-specific configuration options. + # Placeholder for Phase 2 when MySQL adapter is implemented. + class MysqlConfig + def initialize + # No MySQL-specific options yet. + end + + # Freeze mutable collections (none yet), then freeze self. + # Symmetric with PostgresqlConfig#freeze! for consistency. + def freeze! + freeze + end + end + end +end diff --git a/lib/apartment/configs/postgresql_config.rb b/lib/apartment/configs/postgresql_config.rb new file mode 100644 index 00000000..d9bb4e28 --- /dev/null +++ b/lib/apartment/configs/postgresql_config.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Apartment + module Configs + # PostgreSQL-specific configuration options. + class PostgresqlConfig + # Schemas that persist across all tenants (e.g., shared extensions). + attr_accessor :persistent_schemas + + # Whether to verify search_path resets to default after switching away from a tenant. + attr_accessor :enforce_search_path_reset + + # Non-public schemas to include in schema dumps (e.g., %w[ext shared]). + attr_accessor :include_schemas_in_dump + + def initialize + @persistent_schemas = [] + @enforce_search_path_reset = false + @include_schemas_in_dump = [] + end + + # Freeze mutable collections, then freeze self. + def freeze! + @persistent_schemas.freeze + @include_schemas_in_dump.freeze + freeze + end + end + end +end diff --git a/lib/apartment/console.rb b/lib/apartment/console.rb deleted file mode 100644 index 91721222..00000000 --- a/lib/apartment/console.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -# A workaround to get `reload!` to also call Apartment::Tenant.init -# This is unfortunate, but I haven't figured out how to hook into the reload process *after* files are reloaded - -# reloads the environment -# rubocop:disable Style/OptionalBooleanParameter -def reload!(print = true) - puts 'Reloading...' if print - - # This triggers the to_prepare callbacks - ActionDispatch::Callbacks.new(proc {}).call({}) - # Manually init Apartment again once classes are reloaded - Apartment::Tenant.init - true -end -# rubocop:enable Style/OptionalBooleanParameter - -def st(schema_name = nil) - if schema_name.nil? - tenant_list.each { |t| puts t } - - elsif tenant_list.include? schema_name - Apartment::Tenant.switch!(schema_name) - else - puts "Tenant #{schema_name} is not part of the tenant list" - - end -end - -def tenant_list - tenant_list = [Apartment.default_tenant] - tenant_list += Apartment.tenant_names - tenant_list.uniq -end - -def tenant_info_msg - puts "Available Tenants: #{tenant_list}\n" - puts "Use `st 'tenant'` to switch tenants & `tenant_list` to see list\n" -end diff --git a/lib/apartment/current.rb b/lib/apartment/current.rb new file mode 100644 index 00000000..988b5238 --- /dev/null +++ b/lib/apartment/current.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'active_support/current_attributes' + +module Apartment + # Fiber-safe tenant context using ActiveSupport::CurrentAttributes. + # Replaces the v3 Thread.current[:apartment_adapter] approach. + class Current < ActiveSupport::CurrentAttributes + attribute :tenant, :previous_tenant, :migrating + end +end diff --git a/lib/apartment/custom_console.rb b/lib/apartment/custom_console.rb deleted file mode 100644 index 7b32a5b5..00000000 --- a/lib/apartment/custom_console.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require_relative 'console' - -module Apartment - module CustomConsole - begin - 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' - # rubocop:enable Layout/LineLength - end - - desc = "Includes the current Rails environment and project folder name.\n" \ - '[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, '*') } - ] - - if Gem::Version.new(Pry::VERSION) >= Gem::Version.new('0.13') - Pry.config.prompt = Pry::Prompt.new 'ros', desc, prompt_procs - else - 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] - end - - Pry.config.hooks.add_hook(:when_started, 'startup message') do - tenant_info_msg - end - - 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} " - end - end -end diff --git a/lib/apartment/deprecation.rb b/lib/apartment/deprecation.rb deleted file mode 100644 index db73dd5d..00000000 --- a/lib/apartment/deprecation.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'active_support/deprecation' - -module Apartment - module Deprecation - def self.warn(message) - ActiveSupport::Deprecation.warn message - end - end -end diff --git a/lib/apartment/elevators/CLAUDE.md b/lib/apartment/elevators/CLAUDE.md new file mode 100644 index 00000000..9dfda53a --- /dev/null +++ b/lib/apartment/elevators/CLAUDE.md @@ -0,0 +1,328 @@ +# 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 +└── header.rb # Switch based on HTTP header (e.g., X-Tenant-Id) +``` + +## 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. + +### v4 Constructor Pattern + +v4 elevators use constructor keyword args — no class-level mutable state. Options are passed at middleware insertion time: + +```ruby +config.middleware.use Apartment::Elevators::Subdomain, excluded_subdomains: %w[www api] +config.middleware.use Apartment::Elevators::Header, header: 'X-Tenant-Id' +``` + +This replaces the v3 pattern of setting class attributes (`Subdomain.excluded_subdomains = [...]`) after adding to the stack. Each instance carries its own config. + +### 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 + +Extracts subdomain via `PublicSuffix` and checks against `@excluded_subdomains` instance variable. Returns nil for excluded subdomains. See `Subdomain#parse_tenant_name` in `subdomain.rb`. + +### Configuration + +Pass `excluded_subdomains:` keyword arg when adding to middleware stack. 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 + +Pass `excluded_subdomains:` keyword arg when adding to middleware stack. 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 single-subdomain cases, `Subdomain` and `FirstSubdomain` behave identically. `FirstSubdomain` is relevant for nested structures where the tenant is always the leftmost label (e.g., `{tenant}.api.example.com`). + +## 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 + +Pass `ignored_first_subdomains:` keyword arg when adding to middleware stack. 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:` keyword arg 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 + +## Header Elevator + +**Location**: `header.rb` + +### Strategy + +Read a named HTTP header to identify the tenant. Suitable for infrastructure where tenant identity is injected at the edge (CloudFront, Nginx, API gateway). + +### Implementation + +Normalizes the header name to Rack's `HTTP_*` convention at init time. See `Header#parse_tenant_name` in `header.rb`. + +### Configuration + +Pass `header:` keyword arg (defaults to `'X-Tenant-Id'`): + +```ruby +config.middleware.use Apartment::Elevators::Header, header: 'X-Tenant-Id' +``` + +### Security Note + +The `Header` elevator trusts whatever value is in the header. Ensure the header cannot be set by untrusted clients (terminate at a trusted proxy). + +## 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/first_subdomain.rb b/lib/apartment/elevators/first_subdomain.rb index 20ef3e68..fafeb33c 100644 --- a/lib/apartment/elevators/first_subdomain.rb +++ b/lib/apartment/elevators/first_subdomain.rb @@ -4,14 +4,14 @@ module Apartment module Elevators - # Provides a rack based tenant switching solution based on the first subdomain - # of a given domain name. - # eg: - # - example1.domain.com => example1 - # - example2.something.domain.com => example2 + # Tenant from the first segment of nested subdomains. + # acme.staging.example.com -> acme class FirstSubdomain < Subdomain def parse_tenant_name(request) - super.split('.')[0] unless super.nil? + tenant = super + return nil if tenant.nil? + + tenant.split('.').first end end end diff --git a/lib/apartment/elevators/generic.rb b/lib/apartment/elevators/generic.rb index a765486e..e1992daa 100644 --- a/lib/apartment/elevators/generic.rb +++ b/lib/apartment/elevators/generic.rb @@ -8,7 +8,7 @@ module Elevators # Provides a rack based tenant switching solution based on request # class Generic - def initialize(app, processor = nil) + def initialize(app, processor = nil, **_options) @app = app @processor = processor || method(:parse_tenant_name) end @@ -26,7 +26,7 @@ def call(env) end def parse_tenant_name(_request) - raise 'Override' + raise(NotImplementedError, "#{self.class}#parse_tenant_name must be implemented") end end end diff --git a/lib/apartment/elevators/header.rb b/lib/apartment/elevators/header.rb new file mode 100644 index 00000000..3ea1dd77 --- /dev/null +++ b/lib/apartment/elevators/header.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'apartment/elevators/generic' + +module Apartment + module Elevators + # Tenant from HTTP header. For infrastructure that injects tenant identity at the edge + # (CloudFront, Nginx, API gateway). + # + # The trusted: flag is consumed by the Railtie for a boot-time warning; + # the elevator itself behaves identically regardless of trust level. + class Header < Generic + attr_reader :raw_header + + def initialize(app, header: 'X-Tenant-Id', **_options) + super(app) + @header_name = "HTTP_#{header.upcase.tr('-', '_')}" + @raw_header = header.freeze + end + + def parse_tenant_name(request) + request.get_header(@header_name).presence + end + end + end +end diff --git a/lib/apartment/elevators/host.rb b/lib/apartment/elevators/host.rb index 2db8dfd0..0ed30e0c 100644 --- a/lib/apartment/elevators/host.rb +++ b/lib/apartment/elevators/host.rb @@ -4,31 +4,18 @@ module Apartment module Elevators - # Provides a rack based tenant switching solution based on the host - # Assumes that tenant name should match host - # Strips/ignores first subdomains in ignored_first_subdomains - # eg. example.com => example.com - # www.example.bc.ca => www.example.bc.ca - # if ignored_first_subdomains = ['www'] - # www.example.bc.ca => example.bc.ca - # www.a.b.c.d.com => a.b.c.d.com - # + # Tenant from full hostname. Optionally strips ignored first subdomains (e.g., www). class Host < Generic - def self.ignored_first_subdomains - @ignored_first_subdomains ||= [] + def initialize(app, ignored_first_subdomains: [], **_options) + super(app) + @ignored_first_subdomains = Array(ignored_first_subdomains).map(&:to_s).freeze end - # rubocop:disable Style/TrivialAccessors - def self.ignored_first_subdomains=(arg) - @ignored_first_subdomains = arg - end - # rubocop:enable Style/TrivialAccessors - def parse_tenant_name(request) return nil if request.host.blank? parts = request.host.split('.') - self.class.ignored_first_subdomains.include?(parts[0]) ? parts.drop(1).join('.') : request.host + @ignored_first_subdomains.include?(parts[0]) ? parts.drop(1).join('.') : request.host end end end diff --git a/lib/apartment/elevators/host_hash.rb b/lib/apartment/elevators/host_hash.rb index f68c10cb..cd5e2759 100644 --- a/lib/apartment/elevators/host_hash.rb +++ b/lib/apartment/elevators/host_hash.rb @@ -4,20 +4,16 @@ module Apartment module Elevators - # Provides a rack based tenant switching solution based on hosts - # Uses a hash to find the corresponding tenant name for the host - # + # Tenant from hostname -> tenant hash mapping. + # Raises TenantNotFound when host is not in the hash (explicit mapping; missing = config error). class HostHash < Generic - def initialize(app, hash = {}, processor = nil) - super app, processor - @hash = hash + def initialize(app, hash: {}, **_options) + super(app) + @hash = hash.freeze end def parse_tenant_name(request) - unless @hash.key?(request.host) - raise TenantNotFound, - "Cannot find tenant for host #{request.host}" - end + raise(TenantNotFound, request.host) unless @hash.key?(request.host) @hash[request.host] end diff --git a/lib/apartment/elevators/subdomain.rb b/lib/apartment/elevators/subdomain.rb index 38604d60..2a3c12c7 100644 --- a/lib/apartment/elevators/subdomain.rb +++ b/lib/apartment/elevators/subdomain.rb @@ -5,39 +5,24 @@ module Apartment module Elevators - # Provides a rack based tenant switching solution based on subdomains - # Assumes that tenant name should match subdomain - # + # Tenant from subdomain. Uses PublicSuffix for international TLD handling. class Subdomain < Generic - def self.excluded_subdomains - @excluded_subdomains ||= [] + def initialize(app, excluded_subdomains: [], **_options) + super(app) + @excluded_subdomains = Array(excluded_subdomains).map(&:to_s).freeze end - # rubocop:disable Style/TrivialAccessors - def self.excluded_subdomains=(arg) - @excluded_subdomains = arg - end - # rubocop:enable Style/TrivialAccessors - 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. - tenant = if self.class.excluded_subdomains.include?(request_subdomain) - nil - else - request_subdomain - end + return nil if request_subdomain.blank? + return nil if @excluded_subdomains.include?(request_subdomain) - tenant.presence + request_subdomain end protected - # *Almost* a direct ripoff of ActionDispatch::Request subdomain methods - - # Only care about the first subdomain for the database name def subdomain(host) subdomains(host).first end diff --git a/lib/apartment/errors.rb b/lib/apartment/errors.rb new file mode 100644 index 00000000..dd9c5b54 --- /dev/null +++ b/lib/apartment/errors.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Apartment + # Base error for all Apartment exceptions. + class ApartmentError < StandardError; end + + # Raised when a tenant cannot be found. + class TenantNotFound < ApartmentError + attr_reader :tenant + + def initialize(tenant = nil) + @tenant = tenant + super(tenant ? "Tenant '#{tenant}' not found" : 'Tenant not found') + end + end + + # Raised when attempting to create a tenant that already exists. + class TenantExists < ApartmentError + attr_reader :tenant + + def initialize(tenant = nil) + @tenant = tenant + super(tenant ? "Tenant '#{tenant}' already exists" : 'Tenant already exists') + end + end + + # Raised when the requested database adapter is not found. + class AdapterNotFound < ApartmentError; end + + # Raised on invalid configuration. + class ConfigurationError < ApartmentError; end + + # Raised when the tenant connection pool is exhausted. + class PoolExhausted < ApartmentError; end + + # Raised when schema loading fails during tenant creation. + class SchemaLoadError < ApartmentError; end + + # Raised in development when a tenant has pending migrations. + class PendingMigrationError < ApartmentError + attr_reader :tenant + + def initialize(tenant = nil) + @tenant = tenant + super( + if tenant + "Tenant '#{tenant}' has pending migrations. Run apartment:migrate to update." + else + 'Tenant has pending migrations. Run apartment:migrate to update.' + end + ) + end + end +end diff --git a/lib/apartment/instrumentation.rb b/lib/apartment/instrumentation.rb new file mode 100644 index 00000000..02e6d167 --- /dev/null +++ b/lib/apartment/instrumentation.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'active_support/notifications' + +module Apartment + # Thin wrapper around ActiveSupport::Notifications. + # Known events: create, drop, evict (all namespaced as *.apartment). + module Instrumentation + def self.instrument(event, payload = {}, &block) + event_name = "#{event}.apartment" + if block + ActiveSupport::Notifications.instrument(event_name, payload, &block) + else + ActiveSupport::Notifications.instrument(event_name, payload) {} + end + end + end +end diff --git a/lib/apartment/log_subscriber.rb b/lib/apartment/log_subscriber.rb deleted file mode 100644 index 119acbee..00000000 --- a/lib/apartment/log_subscriber.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'active_record/log_subscriber' - -module Apartment - # Custom Log subscriber to include database name and schema name in sql logs - class LogSubscriber < ActiveRecord::LogSubscriber - # NOTE: for some reason, if the method definition is not here, then the custom debug method is not called - # rubocop:disable Lint/UselessMethodDefinition - def sql(event) - super(event) - end - # rubocop:enable Lint/UselessMethodDefinition - - private - - def debug(progname = nil, &block) - progname = " #{apartment_log}#{progname}" unless progname.nil? - - super(progname, &block) - end - - def apartment_log - database = color("[#{Apartment.connection.raw_connection.db}] ", ActiveSupport::LogSubscriber::MAGENTA, true) - schema = nil - unless Apartment.connection.schema_search_path.nil? - schema = color("[#{Apartment.connection.schema_search_path.tr('"', '')}] ", - ActiveSupport::LogSubscriber::YELLOW, true) - end - "#{database}#{schema}" - end - end -end diff --git a/lib/apartment/migrator.rb b/lib/apartment/migrator.rb index 089109df..a81abdd1 100644 --- a/lib/apartment/migrator.rb +++ b/lib/apartment/migrator.rb @@ -1,52 +1,229 @@ # frozen_string_literal: true -require 'apartment/tenant' +require 'concurrent' +require_relative 'instrumentation' +require_relative 'errors' module Apartment - module Migrator - extend self + class Migrator # rubocop:disable Metrics/ClassLength + Result = Data.define( + :tenant, + :status, + :duration, + :error, + :versions_run + ) - # Migrate to latest - def migrate(database) - Tenant.switch(database) do - version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil + MigrationRun = Data.define( + :results, + :total_duration, + :threads + ) do + def succeeded = results.select { _1.status == :success } + def failed = results.select { _1.status == :failed } + def skipped = results.select { _1.status == :skipped } + def success? = failed.empty? - migration_scope_block = ->(migration) { ENV['SCOPE'].blank? || (ENV['SCOPE'] == migration.scope) } - - if activerecord_below_5_2? - ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, version, &migration_scope_block) - else - ActiveRecord::Base.connection.migration_context.migrate(version, &migration_scope_block) + def summary # rubocop:disable Metrics/AbcSize + lines = [] + lines << "Migrated #{results.size} tenants in #{total_duration.round(1)}s (#{threads} threads)" + lines << " #{succeeded.size} succeeded" if succeeded.any? + if failed.any? + lines << " #{failed.size} failed:" + failed.each { |r| lines << " #{r.tenant}: #{r.error&.class}: #{r.error&.message}" } end + lines << " #{skipped.size} skipped (up to date)" if skipped.any? + lines.join("\n") end end - # Migrate up/down to a specific version - def run(direction, database, version) - Tenant.switch(database) do - if activerecord_below_5_2? - ActiveRecord::Migrator.run(direction, ActiveRecord::Migrator.migrations_paths, version) - else - ActiveRecord::Base.connection.migration_context.run(direction, version) + # Wrap a block in connected_to(role: migration_role) when configured. + # Class method so both Migrator internals and CLI commands can share + # the same RBAC role-switching logic without duplication. + def self.with_migration_role(&) + role = Apartment.config.migration_role + role ? ActiveRecord::Base.connected_to(role: role, &) : yield + end + + def initialize(threads: 0, version: nil) + @threads = threads + @version = version + end + + def run # rubocop:disable Metrics/MethodLength + start = monotonic_now + + primary_result = with_migration_role { migrate_primary } + + if primary_result.status == :failed + return MigrationRun.new( + results: [primary_result], + total_duration: monotonic_now - start, + threads: @threads + ) + end + + tenants = Apartment.config.tenants_provider.call + tenant_results = if @threads.positive? + run_parallel(tenants) + else + run_sequential(tenants) + end + + all_results = [primary_result, *tenant_results].compact + + MigrationRun.new( + results: all_results, + total_duration: monotonic_now - start, + threads: @threads + ) + ensure + evict_migration_pools + end + + # Migrate a single named tenant. Returns a Result. + # Evicts migration-role pools in ensure regardless of outcome. + def migrate_one(tenant) + migrate_tenant(tenant) + ensure + evict_migration_pools + end + + private + + # Migrate the primary (default) tenant using AR::Base's existing pool. + # No tenant switch needed — the default connection is already correct. + def migrate_primary # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + tenant_name = Apartment.config.default_tenant || 'public' + start = monotonic_now + + context = ActiveRecord::Base.connection_pool.migration_context + + unless @version || context.needs_migration? + return Result.new( + tenant: tenant_name, status: :skipped, + duration: monotonic_now - start, error: nil, versions_run: [] + ) + end + + raw_versions = context.migrate(@version) + versions = Array(raw_versions).map { _1.respond_to?(:version) ? _1.version : _1 } + + Instrumentation.instrument(:migrate_tenant, tenant: tenant_name, versions: versions) + + Result.new( + tenant: tenant_name, status: :success, + duration: monotonic_now - start, error: nil, versions_run: versions + ) + rescue StandardError => e + Result.new( + tenant: tenant_name, status: :failed, + duration: monotonic_now - start, error: e, versions_run: [] + ) + end + + # Migrate a single tenant by switching via Apartment::Tenant.switch. + # The ConnectionHandling patch routes AR::Base.connection_pool to the + # tenant's pool, so Rails' migration machinery (which always goes through + # AR::Base) uses the correct connection automatically. + # + # Advisory locks are disabled for tenant migrations. PG's advisory locks + # are database-wide, so they serialize all parallel tenant migrations into + # sequential execution. Disabling them is a known trade-off: a migration + # that performs cross-tenant operations could race, but schema-scoped locks + # wouldn't prevent that either (see apartment issue #298). + def migrate_tenant(tenant) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + start = monotonic_now + Apartment::Current.migrating = true + + with_migration_role do + Apartment::Tenant.switch(tenant) do + context = ActiveRecord::Base.connection_pool.migration_context + + unless @version || context.needs_migration? + return Result.new( + tenant: tenant, status: :skipped, + duration: monotonic_now - start, error: nil, versions_run: [] + ) + end + + with_advisory_locks_disabled do + raw_versions = context.migrate(@version) + versions = Array(raw_versions).map { _1.respond_to?(:version) ? _1.version : _1 } + + Instrumentation.instrument(:migrate_tenant, tenant: tenant, versions: versions) + + Result.new( + tenant: tenant, status: :success, + duration: monotonic_now - start, error: nil, versions_run: versions + ) + end end end + rescue StandardError => e + Result.new( + tenant: tenant, status: :failed, + duration: monotonic_now - start, error: e, versions_run: [] + ) + ensure + Apartment::Current.migrating = false end - # rollback latest migration `step` number of times - def rollback(database, step = 1) - Tenant.switch(database) do - if activerecord_below_5_2? - ActiveRecord::Migrator.rollback(ActiveRecord::Migrator.migrations_paths, step) - else - ActiveRecord::Base.connection.migration_context.rollback(step) + def run_sequential(tenants) + tenants.map { |tenant| migrate_tenant(tenant) } + end + + def run_parallel(tenants) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + work_queue = Queue.new + tenants.each { |t| work_queue << t } + @threads.times { work_queue << :done } + + results = Concurrent::Array.new + fatal_errors = Concurrent::Array.new + + workers = Array.new(@threads) do + Thread.new do # rubocop:disable ThreadSafety/NewThread + while (tenant = work_queue.pop) != :done + results << migrate_tenant(tenant) + end + rescue Exception => e # rubocop:disable Lint/RescueException + fatal_errors << e end end + + workers.each(&:join) + raise(fatal_errors.first) if fatal_errors.any? + + results.to_a end - private + def with_migration_role(&) = self.class.with_migration_role(&) + + def evict_migration_pools + role = Apartment.config.migration_role + return unless role && Apartment.pool_manager + + Apartment.pool_manager.evict_by_role(role).each do |pool_key, _pool| # rubocop:disable Style/HashEachMethods + Apartment.deregister_shard(pool_key) + end + rescue StandardError => e + warn "[Apartment::Migrator] Pool eviction failed: #{e.class}: #{e.message}" + end + + # Disable advisory locks on the leased connection for the duration of the + # block, then restore the original value. lease_connection returns the same + # connection object for the current thread (fiber-local via IsolatedExecutionState). + def with_advisory_locks_disabled + conn = ActiveRecord::Base.lease_connection + original = conn.instance_variable_get(:@advisory_locks_enabled) + conn.instance_variable_set(:@advisory_locks_enabled, false) + yield + ensure + conn&.instance_variable_set(:@advisory_locks_enabled, original) + end - def activerecord_below_5_2? - ActiveRecord.version.release < Gem::Version.new('5.2.0') + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) end end end diff --git a/lib/apartment/model.rb b/lib/apartment/model.rb deleted file mode 100644 index 5a75a140..00000000 --- a/lib/apartment/model.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Apartment - module Model - extend ActiveSupport::Concern - - module ClassMethods - # NOTE: key can either be an array of symbols or a single value. - # E.g. If we run the following query: - # `Setting.find_by(key: 'something', value: 'amazing')` key will have an array of symbols: `[:key, :something]` - # while if we run: - # `Setting.find(10)` key will have the value 'id' - 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 - "#{Apartment::Tenant.current}_#{key}" - else - [Apartment::Tenant.current] + key - end - cache = @find_by_statement_cache[connection.prepared_statements] - cache.compute_if_absent(cache_key) { ActiveRecord::StatementCache.create(connection, &block) } - end - end - end -end diff --git a/lib/apartment/patches/connection_handling.rb b/lib/apartment/patches/connection_handling.rb new file mode 100644 index 00000000..8bfbb46e --- /dev/null +++ b/lib/apartment/patches/connection_handling.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'active_record' + +module Apartment + module Patches + # Prepended on ActiveRecord::Base (singleton class) to intercept + # connection_pool lookups. When Apartment::Current.tenant is set, + # returns a tenant-specific pool keyed by "tenant:role", with config + # resolved by the adapter using the current role's base config. + module ConnectionHandling + def connection_pool # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + tenant = Apartment::Current.tenant + cfg = Apartment.config + + return super if tenant.nil? || cfg.nil? + return super if tenant.to_s == cfg.default_tenant.to_s + return super unless Apartment.pool_manager + + # Skip tenant override for Apartment pinned models. + # Uses explicit registry (not connection_specification_name heuristic) + # because ApplicationRecord subclasses have a different spec name than + # ActiveRecord::Base while sharing the same pool. + return super if self != ActiveRecord::Base && Apartment.pinned_model?(self) + + role = ActiveRecord::Base.current_role + pool_key = "#{tenant}:#{role}" + + Apartment.pool_manager.fetch_or_create(pool_key) do + # Resolve base config from the current role's default pool when available. + # Falls back to nil (adapter uses its own base_config) when the default pool + # is not accessible — e.g., in worker threads during parallel migration where + # the ConnectionHandler may not have the pool registered for this context. + # NOTE: `super` must be called here (not in a helper) because it refers to + # the original connection_pool method on AR::Base, which only resolves from + # the prepended method scope. + base = begin + default_pool = super + default_pool.db_config.configuration_hash.stringify_keys + rescue ActiveRecord::ConnectionNotEstablished + nil + end + + config = Apartment.adapter.validated_connection_config(tenant, base_config_override: base) + prefix = cfg.shard_key_prefix + shard_key = :"#{prefix}_#{pool_key}" + + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + cfg.rails_env_name, + "#{prefix}_#{pool_key}", + config + ) + + pool = ActiveRecord::Base.connection_handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: role, + shard: shard_key + ) + + raise(Apartment::PendingMigrationError, tenant) if check_pending_migrations?(pool) + + load_tenant_schema_cache(tenant, pool) if cfg.schema_cache_per_tenant + + pool + end + rescue Apartment::ApartmentError + raise + rescue StandardError => e + raise(Apartment::ApartmentError, + "Failed to resolve connection pool for tenant '#{tenant}': #{e.class}: #{e.message}") + end + + private + + def check_pending_migrations?(pool) + return false unless Apartment.config.check_pending_migrations + return false unless defined?(Rails) && Rails.env.local? # rubocop:disable Rails/UnknownEnv + return false if Apartment::Current.migrating + + pool.migration_context.needs_migration? + end + + def load_tenant_schema_cache(tenant, pool) + require_relative('../schema_cache') + cache_path = Apartment::SchemaCache.cache_path_for(tenant) + return unless File.exist?(cache_path) + + pool.schema_cache.load!(cache_path) + end + end + end +end diff --git a/lib/apartment/pool_manager.rb b/lib/apartment/pool_manager.rb new file mode 100644 index 00000000..8286a947 --- /dev/null +++ b/lib/apartment/pool_manager.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'concurrent' + +module Apartment + class PoolManager + def initialize + @pools = Concurrent::Map.new + @timestamps = Concurrent::Map.new + end + + # Fetch an existing pool or create one via the block. + # Timestamp is updated after pool creation to avoid orphaned timestamps if the block raises. + def fetch_or_create(tenant_key, &) + pool = @pools.compute_if_absent(tenant_key, &) + touch(tenant_key) + pool + end + + def get(tenant_key) + pool = @pools[tenant_key] + touch(tenant_key) if pool + pool + end + + # Delete pool first, then timestamp. This ordering prevents a concurrent + # #get from orphaning a timestamp (get checks @pools, skips touch if absent). + def remove(tenant_key) + pool = @pools.delete(tenant_key) + @timestamps.delete(tenant_key) + pool + end + + def remove_tenant(tenant) + prefix = "#{tenant}:" + removed = [] + @pools.each_key do |key| + next unless key.start_with?(prefix) + + pool = remove(key) + removed << [key, pool] if pool + end + removed + end + + def evict_by_role(role) + suffix = ":#{role}" + removed = [] + @pools.each_key do |key| + next unless key.end_with?(suffix) + + pool = remove(key) + removed << [key, pool] if pool + end + removed + end + + def tracked?(tenant_key) + @pools.key?(tenant_key) + end + + # Returns stats for a tenant pool. Follows ActiveRecord's convention of + # exposing computed durations (seconds_idle) rather than raw monotonic + # timestamps, which are meaningless outside the process. + def stats_for(tenant_key) + return nil unless tracked?(tenant_key) + + { seconds_idle: monotonic_now - @timestamps[tenant_key] } + end + + def idle_tenants(timeout:) + cutoff = monotonic_now - timeout + @timestamps.each_pair.filter_map { |key, ts| key if ts < cutoff } + end + + def lru_tenants(count:) + @timestamps.each_pair + .sort_by { |_, ts| ts } + .first(count) + .map(&:first) + end + + # Basic stats. Full observability (per-tenant breakdown, connection + # counts, eviction counters) deferred to Phase 3. + def stats + { + total_pools: @pools.size, + tenants: @pools.keys, + } + end + + # Disconnect all pools before clearing to prevent connection leaks. + # Each pool's disconnect! is individually rescued so one broken pool + # doesn't prevent cleanup of others. + def clear + @pools.each_pair do |key, pool| + pool.disconnect! if pool.respond_to?(:disconnect!) + rescue StandardError => e + warn "[Apartment::PoolManager] Failed to disconnect pool '#{key}': #{e.class}: #{e.message}" + end + @pools.clear + @timestamps.clear + end + + private + + def touch(tenant_key) + @timestamps[tenant_key] = monotonic_now + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + end +end diff --git a/lib/apartment/pool_reaper.rb b/lib/apartment/pool_reaper.rb new file mode 100644 index 00000000..ecaf7dda --- /dev/null +++ b/lib/apartment/pool_reaper.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'concurrent' +require_relative 'instrumentation' + +module Apartment + # Evicts idle and excess tenant pools on a background timer. + # Complementary to ActiveRecord's ConnectionPool::Reaper which handles + # intra-pool connection reaping — this handles inter-pool (tenant) eviction. + class PoolReaper + def initialize(pool_manager:, interval:, idle_timeout:, max_total: nil, + default_tenant: nil, shard_key_prefix: nil, on_evict: nil) + raise(ArgumentError, 'interval must be a positive number') unless interval.is_a?(Numeric) && interval.positive? + unless idle_timeout.is_a?(Numeric) && idle_timeout.positive? + raise(ArgumentError, 'idle_timeout must be a positive number') + end + if max_total && (!max_total.is_a?(Integer) || max_total < 1) + raise(ArgumentError, 'max_total must be a positive integer or nil') + end + + @pool_manager = pool_manager + @interval = interval + @idle_timeout = idle_timeout + @max_total = max_total + @default_tenant = default_tenant + @shard_key_prefix = shard_key_prefix + @on_evict = on_evict + @mutex = Mutex.new + @timer = nil + end + + def start + @mutex.synchronize do + stop_internal + @timer = Concurrent::TimerTask.new(execution_interval: @interval) { reap } + @timer.execute + end + self + end + + def stop + @mutex.synchronize { stop_internal } + end + + def running? + @mutex.synchronize { @timer&.running? || false } + end + + # Perform one synchronous eviction pass (idle + LRU). + # Returns the total number of pools evicted. + # Called by the background timer and by CLI `pool evict`. + def run_cycle + count = 0 + count += evict_idle + count += evict_lru if @max_total + count + rescue Apartment::ApartmentError => e + warn "[Apartment::PoolReaper] #{e.class}: #{e.message}" + 0 + rescue StandardError => e + warn "[Apartment::PoolReaper] Unexpected error: #{e.class}: #{e.message}" + warn e.backtrace&.first(5)&.join("\n") if e.backtrace + 0 + end + + private + + def stop_internal + return unless @timer + + @timer.shutdown + @timer.wait_for_termination(5) + @timer = nil + end + + def reap + run_cycle + end + + def evict_idle + count = 0 + @pool_manager.idle_tenants(timeout: @idle_timeout).each do |tenant| + next if default_tenant_pool?(tenant) + + pool = @pool_manager.remove(tenant) + deregister_from_ar_handler(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :idle) + @on_evict&.call(tenant, pool) + count += 1 + rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}" + end + count + end + + def evict_lru + excess = @pool_manager.stats[:total_pools] - @max_total + return 0 if excess <= 0 + + candidates = @pool_manager.lru_tenants(count: excess + 1) + evicted = 0 + candidates.each do |tenant| + break if evicted >= excess + next if default_tenant_pool?(tenant) + + pool = @pool_manager.remove(tenant) + deregister_from_ar_handler(tenant) + Instrumentation.instrument(:evict, tenant: tenant, reason: :lru) + @on_evict&.call(tenant, pool) + evicted += 1 + rescue StandardError => e + warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}" + end + evicted + end + + def deregister_from_ar_handler(tenant) + Apartment.deregister_shard(tenant) + end + + def default_tenant_pool?(pool_key) + return false unless @default_tenant + + pool_key == @default_tenant || pool_key.start_with?("#{@default_tenant}:") + end + end +end diff --git a/lib/apartment/railtie.rb b/lib/apartment/railtie.rb index 649c6fcd..1581eefc 100644 --- a/lib/apartment/railtie.rb +++ b/lib/apartment/railtie.rb @@ -1,84 +1,90 @@ # frozen_string_literal: true require 'rails' -require 'apartment/tenant' -require 'apartment/reloader' module Apartment class Railtie < Rails::Railtie - # - # Set up our default config options - # Do this before the app initializers run so we don't override custom settings - # - config.before_initialize do - Apartment.configure do |config| - config.excluded_models = [] - config.use_schemas = true - config.tenant_names = [] - config.seed_after_create = false - config.prepend_environment = false - config.append_environment = false - config.tenant_presence_check = true - config.active_record_log = false - end - - ActiveRecord::Migrator.migrations_paths = Rails.application.paths['db/migrate'].to_a - end + # After all initializers run: wire up Apartment if configured. + config.after_initialize do + next unless Apartment.config - # Hook into ActionDispatch::Reloader to ensure Apartment is properly initialized - # Note that this doesn't entirely work as expected in Development, - # because this is called before classes are reloaded - # See the middleware/console declarations below to help with this. Hope to fix that soon. - # - config.to_prepare do - next if ARGV.any? { |arg| arg =~ /\Aassets:(?:precompile|clean)\z/ } - next if ARGV.any? { |arg| arg == 'webpacker:compile' } - next if ENV['APARTMENT_DISABLE_INIT'] + # v4 requires fiber isolation for correct CurrentAttributes propagation. + if defined?(ActiveSupport::IsolatedExecutionState) && + ActiveSupport::IsolatedExecutionState.isolation_level == :thread + warn '[Apartment] WARNING: ActiveSupport isolation_level is :thread. ' \ + 'Apartment v4 requires :fiber for correct CurrentAttributes propagation. ' \ + 'Set config.active_support.isolation_level = :fiber in your application config.' + end begin - Apartment.connection_class.connection_pool.with_connection do - Apartment::Tenant.init - end - rescue ::ActiveRecord::NoDatabaseError - # Since `db:create` and other tasks invoke this block from Rails 5.2.0, - # we need to swallow the error to execute `db:create` properly. + Apartment.activate! + Apartment.activate_sql_query_tags! + Apartment::Tenant.init + + # Apply schema dumper patch for Rails 8.1+ (public. prefix stripping) + require('apartment/schema_dumper_patch') + Apartment::SchemaDumperPatch.apply! + rescue ActiveRecord::NoDatabaseError + warn '[Apartment] Database not found during init — skipping. Run db:create first.' end end - config.after_initialize do - # NOTE: Load the custom log subscriber if enabled - if Apartment.active_record_log - ActiveSupport::Notifications.unsubscribe 'sql.active_record' - Apartment::LogSubscriber.attach_to :active_record + # Insert elevator middleware if configured. + initializer 'apartment.middleware' do |app| + next unless Apartment.config&.elevator + + elevator_class = Apartment::Railtie.resolve_elevator_class(Apartment.config.elevator) + opts = Apartment.config.elevator_options || {} + + if Apartment::Railtie.header_trust_warning?(elevator_class, opts) + warn <<~WARNING + [Apartment] WARNING: Header elevator with trusted: false. + Header-based tenant resolution trusts the client to provide the correct tenant. + Only use this when the header is injected by trusted infrastructure (CDN, reverse proxy) + that strips client-supplied values. + WARNING end + + app.middleware.use(elevator_class, **opts) end - # - # Ensure rake tasks are loaded - # rake_tasks do - load 'tasks/apartment.rake' - require 'apartment/tasks/enhancements' if Apartment.db_migrate_tenants - end + load File.expand_path('tasks/v4.rake', __dir__) - # - # The following initializers are a workaround to the fact that I can't properly hook into the rails reloader - # Note this is technically valid for any environment where cache_classes is false, for us, it's just development - # - if Rails.env.development? + # Enhance db:migrate:DBNAME to also run apartment:migrate. + # configs_for and task_defined? are pure config/rake lookups — no DB + # connection is made, so no rescue is needed. + primary_db_name = ActiveRecord::Base.configurations + .configs_for(env_name: Rails.env) + .find { |c| c.name == 'primary' } + &.name || 'primary' - # Apartment::Reloader is middleware to initialize things properly on each request to dev - initializer 'apartment.init' do |app| - app.config.middleware.use Apartment::Reloader + if Rake::Task.task_defined?("db:migrate:#{primary_db_name}") + Rake::Task["db:migrate:#{primary_db_name}"].enhance do + Rake::Task['apartment:migrate'].invoke if Rake::Task.task_defined?('apartment:migrate') + end end + end - # Overrides reload! to also call Apartment::Tenant.init as well - # so that the reloaded classes have the proper table_names - # rubocop:disable Lint/Debugger - console do - require 'apartment/console' - end - # rubocop:enable Lint/Debugger + # Whether the Header elevator trust warning should fire. Class method for testability. + def self.header_trust_warning?(elevator_class, opts) + elevator_class <= Apartment::Elevators::Header && !opts[:trusted] + end + + # Resolve an elevator symbol/string to its class. Class method for testability. + # Accepts a Class directly (pass-through) or a symbol/string for lookup. + def self.resolve_elevator_class(elevator) + return elevator if elevator.is_a?(Class) + + class_name = "Apartment::Elevators::#{elevator.to_s.camelize}" + require("apartment/elevators/#{elevator}") + class_name.constantize + rescue NameError, LoadError => e + available = Dir[File.join(__dir__, 'elevators', '*.rb')] + .filter_map { |f| File.basename(f, '.rb').then { |name| name unless name == 'generic' } } + raise(Apartment::ConfigurationError, + "Unknown elevator '#{elevator}': #{e.message}. " \ + "Available elevators: #{available.join(', ')}") end end end diff --git a/lib/apartment/reloader.rb b/lib/apartment/reloader.rb deleted file mode 100644 index cd8b6861..00000000 --- a/lib/apartment/reloader.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Apartment - class Reloader - # Middleware used in development to init Apartment for each request - # Necessary due to code reload (annoying). When models are reloaded, they no longer have the proper table_name - # That is prepended with the schema (if using postgresql schemas) - # I couldn't figure out how to properly hook into the Rails reload process *after* files are reloaded - # so I've used this in the meantime. - # - # Also see apartment/console for the re-definition of reload! that re-init's Apartment - # - def initialize(app) - @app = app - end - - def call(env) - Tenant.init - @app.call(env) - end - end -end diff --git a/lib/apartment/schema_cache.rb b/lib/apartment/schema_cache.rb new file mode 100644 index 00000000..9ee34f2f --- /dev/null +++ b/lib/apartment/schema_cache.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'pathname' + +module Apartment + module SchemaCache + module_function + + def dump(tenant) + path = cache_path_for(tenant) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.schema_cache.dump_to(path) + end + path + end + + def dump_all + Apartment.config.tenants_provider.call.map { |t| dump(t) } + end + + def cache_path_for(tenant) + base = defined?(Rails) && Rails.root ? Rails.root.join('db') : Pathname.new('db') + base.join("schema_cache_#{tenant}.yml").to_s + end + end +end diff --git a/lib/apartment/schema_dumper_patch.rb b/lib/apartment/schema_dumper_patch.rb new file mode 100644 index 00000000..57a1ef1a --- /dev/null +++ b/lib/apartment/schema_dumper_patch.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Apartment + module SchemaDumperPatch + def self.strip_public_prefix(table_name, include_schemas: []) + schema, name = table_name.split('.', 2) + + return table_name unless name + + return table_name if schema != 'public' && include_schemas.include?(schema) + + return name if schema == 'public' + + table_name + end + + def self.apply! + return unless should_patch? + + # Rails 8.1+ adds schema-qualified names via `relation_name` in the + # PG-specific SchemaDumper (PR #50020). The prefix is applied to tables, + # foreign keys, enums, and indexes — all through `relation_name`. We + # intercept that single method rather than patching each call site. + return unless defined?(ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper) + + ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.prepend(DumperOverride) + end + + def self.should_patch? + return false unless defined?(ActiveRecord::SchemaDumper) + + ActiveRecord.gem_version >= Gem::Version.new('8.1.0') + end + + module DumperOverride + private + + def relation_name(name) + result = super + pg_config = Apartment.config&.postgres_config + return result unless pg_config + + include_schemas = pg_config.include_schemas_in_dump || [] + SchemaDumperPatch.strip_public_prefix(result, include_schemas: include_schemas) + end + end + end +end diff --git a/lib/apartment/tasks/CLAUDE.md b/lib/apartment/tasks/CLAUDE.md new file mode 100644 index 00000000..19d23f82 --- /dev/null +++ b/lib/apartment/tasks/CLAUDE.md @@ -0,0 +1,30 @@ +# lib/apartment/tasks/ - Rake Task Infrastructure + +This directory contains v4 rake task definitions for Apartment tenant operations. + +## Files + +### v4.rake + +**Purpose**: Defines the `apartment:` rake task namespace for tenant lifecycle operations. + +**Tasks**: +- `apartment:create` — Create tenant schemas/databases for all configured tenants +- `apartment:drop` — Drop tenant schemas/databases for all configured tenants +- `apartment:migrate` — Run migrations across all tenant schemas/databases +- `apartment:seed` — Seed all tenant schemas/databases +- `apartment:rollback` — Roll back migrations across all tenant schemas/databases + +**Loading**: Loaded by `Railtie#rake_tasks` hook (see `lib/apartment/railtie.rb`). Not loaded outside a Rails context. + +## Relationship to Other Components + +- **Railtie** (`lib/apartment/railtie.rb`): Loads `tasks/v4.rake` via the `rake_tasks` block. +- **Tenant API** (`lib/apartment/tenant.rb`): Tasks delegate lifecycle operations to `Apartment::Tenant`. +- **Configuration** (`lib/apartment/config.rb`): Tasks respect `Apartment.config` (tenants_provider, excluded_models, etc.). + +## Notes + +v3 task helpers (`task_helper.rb`, `enhancements.rb`, `schema_dumper.rb`) and the top-level `lib/tasks/apartment.rake` have been deleted as of Phase 2.5. + +`apartment:migrate` delegates to `Apartment::Migrator`, reading `parallel_migration_threads` from `Apartment.config`. Supports `VERSION=` env var for targeting a specific migration. When `parallel_migration_threads > 0`, migration runs across a thread pool of that size. After a successful run, schema dump is triggered only if `ActiveRecord.dump_schema_after_migration` is true (via `db:schema:dump` rake task). Failed tenants abort the task with a non-zero exit. All tasks (`create`, `seed`, `rollback`) abort non-zero on any tenant failure. diff --git a/lib/apartment/tasks/enhancements.rb b/lib/apartment/tasks/enhancements.rb deleted file mode 100644 index f9a13206..00000000 --- a/lib/apartment/tasks/enhancements.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -# Require this file to append Apartment rake tasks to ActiveRecord db rake tasks -# Enabled by default in the initializer - -module Apartment - class RakeTaskEnhancer - module TASKS - ENHANCE_BEFORE = %w[db:drop].freeze - ENHANCE_AFTER = %w[db:migrate db:rollback db:migrate:up db:migrate:down db:migrate:redo db:seed].freeze - freeze - end - - # This is a bit convoluted, but helps solve problems when using Apartment within an engine - # See spec/integration/use_within_an_engine.rb - - class << self - def enhance! - return unless should_enhance? - - # insert task before - TASKS::ENHANCE_BEFORE.each do |name| - task = Rake::Task[name] - enhance_before_task(task) - end - - # insert task after - TASKS::ENHANCE_AFTER.each do |name| - task = Rake::Task[name] - enhance_after_task(task) - end - end - - def should_enhance? - Apartment.db_migrate_tenants - end - - def enhance_before_task(task) - task.enhance([inserted_task_name(task)]) - end - - def enhance_after_task(task) - task.enhance do - Rake::Task[inserted_task_name(task)].invoke - end - end - - def inserted_task_name(task) - task.name.sub(/db:/, 'apartment:') - end - end - end -end - -Apartment::RakeTaskEnhancer.enhance! diff --git a/lib/apartment/tasks/task_helper.rb b/lib/apartment/tasks/task_helper.rb deleted file mode 100644 index 32cd908e..00000000 --- a/lib/apartment/tasks/task_helper.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module Apartment - module TaskHelper - def self.each_tenant(&block) - Parallel.each(tenants_without_default, in_threads: Apartment.parallel_migration_threads) do |tenant| - block.call(tenant) - end - end - - def self.tenants_without_default - tenants - [Apartment.default_tenant] - end - - def self.tenants - ENV['DB'] ? ENV['DB'].split(',').map(&:strip) : Apartment.tenant_names || [] - end - - def self.warn_if_tenants_empty - return unless tenants.empty? && ENV['IGNORE_EMPTY_TENANTS'] != 'true' - - puts <<-WARNING - [WARNING] - The list of tenants to migrate appears to be empty. This could mean a few things: - - 1. You may not have created any, in which case you can ignore this message - 2. You've run `apartment:migrate` directly without loading the Rails environment - * `apartment:migrate` is now deprecated. Tenants will automatically be migrated with `db:migrate` - - Note that your tenants currently haven't been migrated. You'll need to run `db:migrate` to rectify this. - WARNING - end - - def self.create_tenant(tenant_name) - puts("Creating #{tenant_name} tenant") - Apartment::Tenant.create(tenant_name) - rescue Apartment::TenantExists => e - puts "Tried to create already existing tenant: #{e}" - end - - def self.migrate_tenant(tenant_name) - strategy = Apartment.db_migrate_tenant_missing_strategy - create_tenant(tenant_name) if strategy == :create_tenant - - puts("Migrating #{tenant_name} tenant") - Apartment::Migrator.migrate tenant_name - rescue Apartment::TenantNotFound => e - raise e if strategy == :raise_exception - - puts e.message - end - end -end diff --git a/lib/apartment/tasks/v4.rake b/lib/apartment/tasks/v4.rake new file mode 100644 index 00000000..27392ee7 --- /dev/null +++ b/lib/apartment/tasks/v4.rake @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'apartment/cli' + +namespace :apartment do + desc 'Create all tenant schemas/databases (or one: rake apartment:create[tenant])' + task :create, [:tenant] => :environment do |_t, args| + if args[:tenant] + Apartment::CLI::Tenants.new.invoke(:create, [args[:tenant]]) + else + Apartment::CLI::Tenants.new.invoke(:create) + end + end + + desc 'Drop a tenant schema/database' + task :drop, [:tenant] => :environment do |_t, args| + abort('Usage: rake apartment:drop[tenant_name]') unless args[:tenant] + Apartment::CLI::Tenants.new.invoke(:drop, [args[:tenant]], force: true) + end + + desc 'Run migrations for all tenants (or one: rake apartment:migrate[tenant])' + task :migrate, [:tenant] => :environment do |_t, args| + if args[:tenant] + Apartment::CLI::Migrations.new.invoke(:migrate, [args[:tenant]]) + else + Apartment::CLI::Migrations.new.invoke(:migrate) + end + end + + desc 'Seed all tenants' + task seed: :environment do + Apartment::CLI::Seeds.new.invoke(:seed) + end + + desc 'Rollback migrations for all tenants' + task :rollback, [:step] => :environment do |_t, args| + Apartment::CLI::Migrations.new.invoke(:rollback, [], step: (args[:step] || 1).to_i) + end + + namespace :schema do + namespace :cache do + desc 'Dump schema cache for each tenant' + task dump: :environment do + require 'apartment/schema_cache' + paths = Apartment::SchemaCache.dump_all + paths.each { |p| puts("Dumped: #{p}") } + end + end + end +end diff --git a/lib/apartment/tenant.rb b/lib/apartment/tenant.rb index abbe87d5..937b6538 100644 --- a/lib/apartment/tenant.rb +++ b/lib/apartment/tenant.rb @@ -1,63 +1,102 @@ # frozen_string_literal: true -require 'forwardable' - module Apartment - # The main entry point to Apartment functions - # module Tenant - extend self - extend Forwardable - - def_delegators :adapter, :create, :drop, :switch, :switch!, :current, :each, - :reset, :init, :set_callback, :seed, :default_tenant, :environmentify - - attr_writer :config - - # Fetch the proper multi-tenant adapter based on Rails config - # - # @return {subclass of Apartment::AbstractAdapter} - # - def adapter - Thread.current[:apartment_adapter] ||= begin - adapter_method = "#{config[:adapter]}_adapter" - - if defined?(JRUBY_VERSION) - case config[:adapter] - when /mysql/ - adapter_method = 'jdbc_mysql_adapter' - when /postgresql/ - adapter_method = 'jdbc_postgresql_adapter' - end - end + class << self + # Switch to a tenant for the duration of the block. + # Guaranteed cleanup via ensure — tenant context is always restored. + # + # Note: previous_tenant reflects only the immediately preceding tenant + # for the current switch scope. It is not stacked across nesting levels — + # after an inner switch completes, previous_tenant resets to nil. + def switch(tenant, &block) + raise(ArgumentError, 'Apartment::Tenant.switch requires a block') unless block - begin - require "apartment/adapters/#{adapter_method}" - rescue LoadError - raise "The adapter `#{adapter_method}` is not yet supported" + previous = Current.tenant + Current.tenant = tenant + Current.previous_tenant = previous + if tagged_logging? + Rails.logger.tagged("tenant=#{tenant}", &block) + else + yield end + ensure + Current.tenant = previous + Current.previous_tenant = nil + end - unless respond_to?(adapter_method) - raise AdapterNotFound, "database configuration specifies nonexistent #{config[:adapter]} adapter" - end + # Direct switch without block. Discouraged — prefer switch with block. + def switch!(tenant) + Current.previous_tenant = Current.tenant + Current.tenant = tenant + end - send(adapter_method, config) + # Current tenant name. + def current + Current.tenant || Apartment.config&.default_tenant end - end - # Reset config and adapter so they are regenerated - # - def reload!(config = nil) - Thread.current[:apartment_adapter] = nil - @config = config - end + # Reset to default tenant. + def reset + switch!(Apartment.config&.default_tenant) + end + + # Initialize: resolve excluded_models shim, then process pinned models. + def init + resolve_excluded_models_shim + adapter.process_pinned_models + end + + # Delegate lifecycle operations to the adapter. + def create(tenant) + adapter.create(tenant) + end + + def drop(tenant) + adapter.drop(tenant) + end - private + def migrate(tenant, version = nil) + adapter.migrate(tenant, version) + end + + def seed(tenant) + adapter.seed(tenant) + end + + # Pool stats delegated to pool_manager. + def pool_stats + Apartment.pool_manager&.stats || {} + end + + private + + def adapter + Apartment.adapter or + raise(ConfigurationError, 'Apartment adapter not configured. Call Apartment.configure first.') + end + + def tagged_logging? + Apartment.config&.active_record_log && + defined?(Rails) && Rails.logger.respond_to?(:tagged) + end + + # Resolve config.excluded_models strings into pinned model registrations. + # This is the deprecated compatibility path — new code should use + # `include Apartment::Model` + `pin_tenant` in each model. + def resolve_excluded_models_shim + return if Apartment.config.excluded_models.empty? - # Fetch the rails database configuration - # - def config - @config ||= Apartment.connection_config + Apartment.config.excluded_models.each do |model_name| + klass = model_name.constantize + next if Apartment.pinned_models.include?(klass) + + Apartment.register_pinned_model(klass) + rescue NameError => e + raise(Apartment::ConfigurationError, + "Excluded model '#{model_name}' could not be resolved: #{e.message}") + end + end end end end diff --git a/lib/apartment/tenant_name_validator.rb b/lib/apartment/tenant_name_validator.rb new file mode 100644 index 00000000..a7abe58a --- /dev/null +++ b/lib/apartment/tenant_name_validator.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Apartment + module TenantNameValidator + module_function + + # Validate a tenant name against common and engine-specific rules. + # Raises ConfigurationError on invalid names. Pure in-memory check — no IO. + def validate!(name, strategy:, adapter_name: nil) + validate_common!(name) + case strategy + when :schema + validate_postgresql_identifier!(name) + when :database_name + validate_for_adapter!(name, adapter_name) + end + # :shard and :database_config use common validation only (not yet implemented). + end + + # --- Common rules (all engines) --- + + def validate_common!(name) + raise(ConfigurationError, 'Tenant name must be a String') unless name.is_a?(String) + raise(ConfigurationError, 'Tenant name cannot be empty') if name.empty? + raise(ConfigurationError, "Tenant name contains NUL byte: #{name.inspect}") if name.include?("\x00") + raise(ConfigurationError, "Tenant name contains whitespace: #{name.inspect}") if name.match?(/\s/) + raise(ConfigurationError, "Tenant name contains colon: #{name.inspect}") if name.include?(':') + return unless name.length > 255 + + raise(ConfigurationError, "Tenant name too long (#{name.length} chars, max 255): #{name.inspect}") + end + + # --- PostgreSQL identifiers (schema names, database names) --- + # Hyphens are allowed — our adapters quote via quote_table_name. + # Cannot start with pg_ (reserved prefix). + + def validate_postgresql_identifier!(name) + if name.length > 63 + raise(ConfigurationError, "PostgreSQL identifier too long (#{name.length} chars, max 63): #{name.inspect}") + end + unless name.match?(/\A[a-zA-Z_][a-zA-Z0-9_-]*\z/) + raise(ConfigurationError, + "Invalid PostgreSQL identifier: #{name.inspect}. " \ + 'Must start with letter/underscore, contain only letters, digits, underscores, hyphens') + end + return unless name.start_with?('pg_') + + raise(ConfigurationError, "Tenant name cannot start with 'pg_' (reserved prefix): #{name.inspect}") + end + + # --- MySQL database names --- + # Max 64 chars, allowed: [a-zA-Z0-9_$-], no leading digit, no trailing dot. + + def validate_mysql_database_name!(name) + if name.length > 64 + raise(ConfigurationError, "MySQL database name too long (#{name.length} chars, max 64): #{name.inspect}") + end + raise(ConfigurationError, "MySQL database name cannot start with a digit: #{name.inspect}") if name.match?(/\A\d/) + raise(ConfigurationError, "MySQL database name cannot end with a period: #{name.inspect}") if name.end_with?('.') + return unless name.match?(/[^a-zA-Z0-9_$-]/) + + raise(ConfigurationError, + "Invalid MySQL database name: #{name.inspect}. " \ + 'Allowed characters: letters, digits, underscore, dollar sign, hyphen') + end + + # --- SQLite file paths --- + # No path traversal, filesystem-safe characters. + + def validate_sqlite_path!(name) + raise(ConfigurationError, "SQLite tenant name contains path traversal: #{name.inspect}") if name.include?('..') + return unless name.match?(%r{[/\\]}) + + raise(ConfigurationError, "SQLite tenant name contains path separators: #{name.inspect}") + end + + # --- Dispatcher for :database_name strategy --- + + def validate_for_adapter!(name, adapter_name) + case adapter_name + when /mysql/i, /trilogy/i then validate_mysql_database_name!(name) + when /postgresql/i, /postgis/i then validate_postgresql_identifier!(name) + when /sqlite/i then validate_sqlite_path!(name) + end + end + end +end diff --git a/lib/apartment/version.rb b/lib/apartment/version.rb index 7ec53121..b546148c 100644 --- a/lib/apartment/version.rb +++ b/lib/apartment/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Apartment - VERSION = '2.9.0' + VERSION = '4.0.0.alpha1' end diff --git a/lib/generators/apartment/install/install_generator.rb b/lib/generators/apartment/install/install_generator.rb index 9fe7152e..dfd7880b 100755 --- a/lib/generators/apartment/install/install_generator.rb +++ b/lib/generators/apartment/install/install_generator.rb @@ -4,8 +4,13 @@ module Apartment class InstallGenerator < Rails::Generators::Base source_root File.expand_path('templates', __dir__) - def copy_files - template 'apartment.rb', File.join('config', 'initializers', 'apartment.rb') + def copy_initializer + template('apartment.rb', File.join('config', 'initializers', 'apartment.rb')) + end + + def copy_binstub + template('binstub', File.join('bin', 'apartment')) + chmod(File.join('bin', 'apartment'), 0o755) end end end diff --git a/lib/generators/apartment/install/templates/apartment.rb b/lib/generators/apartment/install/templates/apartment.rb index 17f73c60..d73fc8f3 100644 --- a/lib/generators/apartment/install/templates/apartment.rb +++ b/lib/generators/apartment/install/templates/apartment.rb @@ -1,116 +1,73 @@ # frozen_string_literal: true -# You can have Apartment route to the appropriate Tenant by adding some Rack middleware. -# Apartment can support many different "Elevators" that can take care of this routing to your data. -# Require whichever Elevator you're using below or none if you have a custom one. -# -# require 'apartment/elevators/generic' -# require 'apartment/elevators/domain' -require 'apartment/elevators/subdomain' -# require 'apartment/elevators/first_subdomain' -# require 'apartment/elevators/host' - -# -# Apartment Configuration -# Apartment.configure do |config| - # Add any models that you do not want to be multi-tenanted, but remain in the global (public) namespace. - # A typical example would be a Customer or Tenant model that stores each Tenant's information. - # - # config.excluded_models = %w{ Tenant } - - # In order to migrate all of your Tenants you need to provide a list of Tenant names to Apartment. - # You can make this dynamic by providing a Proc object to be called on migrations. - # This object should yield either: - # - an array of strings representing each Tenant name. - # - a hash which keys are tenant names, and values custom db config - # (must contain all key/values required in database.yml) + # == Required =========================================================== + + # Tenant isolation strategy. + # :schema - PostgreSQL schemas (one schema per tenant, single DB) + # :database_name - Separate database per tenant (MySQL, PostgreSQL) + config.tenant_strategy = :schema + + # Returns an array of tenant identifiers. Called at runtime by migrate, + # create, seed, and other bulk operations. + config.tenants_provider = -> { raise('TODO: replace with e.g. Account.pluck(:subdomain)') } + + # == Tenant Defaults ===================================================== + + # The default tenant (used on boot and between requests). + # config.default_tenant = 'public' + + # Models that live in the shared/default schema (not per-tenant). + # The recommended approach is to declare this in the model itself: # - # config.tenant_names = lambda{ Customer.pluck(:tenant_name) } - # config.tenant_names = ['tenant1', 'tenant2'] - # config.tenant_names = { - # 'tenant1' => { - # adapter: 'postgresql', - # host: 'some_server', - # port: 5555, - # database: 'postgres' # this is not the name of the tenant's db - # # but the name of the database to connect to before creating the tenant's db - # # mandatory in postgresql - # }, - # 'tenant2' => { - # adapter: 'postgresql', - # database: 'postgres' # this is not the name of the tenant's db - # # but the name of the database to connect to before creating the tenant's db - # # mandatory in postgresql - # } - # } - # config.tenant_names = lambda do - # Tenant.all.each_with_object({}) do |tenant, hash| - # hash[tenant.name] = tenant.db_configuration + # class Account < ApplicationRecord + # include Apartment::Model + # pin_tenant # end - # end # - config.tenant_names = -> { ToDo_Tenant_Or_User_Model.pluck :database } + # Legacy alternative (deprecated in v4, removed in v5): + # config.excluded_models = %w[Account] - # PostgreSQL: - # Specifies whether to use PostgreSQL schemas or create a new database per Tenant. - # - # MySQL: - # Specifies whether to switch databases by using `use` statement or re-establish connection. - # - # The default behaviour is true. - # - # config.use_schemas = true + # == Connection Pool ===================================================== - # - # ==> PostgreSQL only options + # config.tenant_pool_size = 5 + # config.pool_idle_timeout = 300 + # config.max_total_connections = nil - # Apartment can be forced to use raw SQL dumps instead of schema.rb for creating new schemas. - # Use this when you are using some extra features in PostgreSQL that can't be represented in - # schema.rb, like materialized views etc. (only applies with use_schemas set to true). - # (Note: this option doesn't use db/structure.sql, it creates SQL dump by executing pg_dump) - # - # config.use_sql = false + # == Elevator (Request Tenant Detection) ================================= - # There are cases where you might want some schemas to always be in your search_path - # e.g when using a PostgreSQL extension like hstore. - # Any schemas added here will be available along with your selected Tenant. + # The Railtie auto-inserts the elevator as middleware. + # No manual insertion into config.middleware is needed. # - # config.persistent_schemas = %w{ hstore } + # config.elevator = :subdomain + # config.elevator_options = {} - # <== PostgreSQL only options - # + # == Logging ============================================================== - # By default, and only when not using PostgreSQL schemas, Apartment will prepend the environment - # to the tenant name to ensure there is no conflict between your environments. - # This is mainly for the benefit of your development and test environments. - # Uncomment the line below if you want to disable this behaviour in production. - # - # config.prepend_environment = !Rails.env.production? - - # When using PostgreSQL schemas, the database dump will be namespaced, and - # apartment will substitute the default namespace (usually public) with the - # name of the new tenant when creating a new tenant. Some items must maintain - # a reference to the default namespace (ie public) - for instance, a default - # uuid generation. Uncomment the line below to create a list of namespaced - # items in the schema dump that should *not* have their namespace replaced by - # the new tenant - # - # config.pg_excluded_names = ["uuid_generate_v4"] + # config.active_record_log = false # Tag Rails logs with [tenant=name] + # config.sql_query_tags = false # Add /* tenant='name' */ SQL comments - # Specifies whether the database and schema (when using PostgreSQL schemas) will prepend in ActiveRecord log. - # Uncomment the line below if you want to enable this behavior. - # - # config.active_record_log = true -end + # == Migrations ========================================================== + + # config.parallel_migration_threads = 0 + # config.schema_load_strategy = nil # :schema_rb or :sql + # config.seed_after_create = false + # config.check_pending_migrations = true + + # == RBAC & Roles ========================================================= -# Setup a custom Tenant switching middleware. The Proc should return the name of the Tenant that -# you want to switch to. -# Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request| -# request.host.split('.').first -# } + # config.migration_role = nil # e.g. :db_manager (Phase 5 role-aware connections) + # config.app_role = nil # e.g. 'app_role' or -> { "app_#{Rails.env}" } + # config.environmentify_strategy = nil # nil, :prepend, :append, or a callable -# Rails.application.config.middleware.use Apartment::Elevators::Domain -Rails.application.config.middleware.use Apartment::Elevators::Subdomain -# Rails.application.config.middleware.use Apartment::Elevators::FirstSubdomain -# Rails.application.config.middleware.use Apartment::Elevators::Host + # == PostgreSQL =========================================================== + + # config.configure_postgres do |pg| + # pg.persistent_schemas = %w[shared extensions] + # end + + # == MySQL ================================================================ + + # config.configure_mysql do |my| + # end +end diff --git a/lib/generators/apartment/install/templates/binstub b/lib/generators/apartment/install/templates/binstub new file mode 100755 index 00000000..23dfbbcd --- /dev/null +++ b/lib/generators/apartment/install/templates/binstub @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../config/environment' +require 'apartment/cli' +Apartment::CLI.start(ARGV) diff --git a/lib/tasks/apartment.rake b/lib/tasks/apartment.rake deleted file mode 100644 index 5ae47c0f..00000000 --- a/lib/tasks/apartment.rake +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -require 'apartment/migrator' -require 'apartment/tasks/task_helper' -require 'parallel' - -apartment_namespace = namespace :apartment do - desc 'Create all tenants' - task :create do - Apartment::TaskHelper.warn_if_tenants_empty - - Apartment::TaskHelper.tenants.each do |tenant| - Apartment::TaskHelper.create_tenant(tenant) - end - end - - desc 'Drop all tenants' - task :drop do - Apartment::TaskHelper.tenants.each do |tenant| - begin - puts("Dropping #{tenant} tenant") - Apartment::Tenant.drop(tenant) - rescue Apartment::TenantNotFound, ActiveRecord::NoDatabaseError => e - puts e.message - end - end - end - - desc 'Migrate all tenants' - task :migrate 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 - Apartment::TaskHelper.warn_if_tenants_empty - - Apartment::TaskHelper.each_tenant do |tenant| - begin - Apartment::TaskHelper.create_tenant(tenant) - puts("Seeding #{tenant} tenant") - Apartment::Tenant.switch(tenant) do - Apartment::Tenant.seed - end - rescue Apartment::TenantNotFound => e - puts e.message - end - end - end - - desc 'Rolls the migration back to the previous version (specify steps w/ STEP=n) across all tenants.' - task :rollback do - Apartment::TaskHelper.warn_if_tenants_empty - - step = ENV['STEP'] ? ENV['STEP'].to_i : 1 - - Apartment::TaskHelper.each_tenant do |tenant| - begin - puts("Rolling back #{tenant} tenant") - Apartment::Migrator.rollback tenant, step - rescue Apartment::TenantNotFound => e - puts e.message - end - end - end - - namespace :migrate do - desc 'Runs the "up" for a given migration VERSION across all tenants.' - task :up do - Apartment::TaskHelper.warn_if_tenants_empty - - version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil - raise 'VERSION is required' unless version - - Apartment::TaskHelper.each_tenant do |tenant| - begin - puts("Migrating #{tenant} tenant up") - Apartment::Migrator.run :up, tenant, version - rescue Apartment::TenantNotFound => e - puts e.message - end - end - end - - desc 'Runs the "down" for a given migration VERSION across all tenants.' - task :down do - Apartment::TaskHelper.warn_if_tenants_empty - - version = ENV['VERSION'] ? ENV['VERSION'].to_i : nil - raise 'VERSION is required' unless version - - Apartment::TaskHelper.each_tenant do |tenant| - begin - puts("Migrating #{tenant} tenant down") - Apartment::Migrator.run :down, tenant, version - rescue Apartment::TenantNotFound => e - puts e.message - end - end - end - - 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 - else - apartment_namespace['rollback'].invoke - apartment_namespace['migrate'].invoke - end - end - end -end diff --git a/ros-apartment.gemspec b/ros-apartment.gemspec index 95a60c35..d7c563f0 100644 --- a/ros-apartment.gemspec +++ b/ros-apartment.gemspec @@ -7,47 +7,30 @@ Gem::Specification.new do |s| s.name = 'ros-apartment' s.version = Apartment::VERSION - s.authors = ['Ryan Brunner', 'Brad Robertson', 'Rui Baltazar'] - s.summary = 'A Ruby gem for managing database multitenancy. Apartment Gem drop in replacement' - s.description = 'Apartment allows Rack applications to deal with database multitenancy through ActiveRecord' - s.email = ['ryan@influitive.com', 'brad@influitive.com', 'rui.p.baltazar@gmail.com'] - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been - # added into git. - s.files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0").reject do |f| - # NOTE: ignore all test related - f.match(%r{^(test|spec|features|documentation)/}) - end - end - s.executables = s.files.grep(%r{^bin/}).map { |f| File.basename(f) } - s.test_files = s.files.grep(%r{^(test|spec|features)/}) + s.authors = ['Ryan Brunner', 'Brad Robertson', 'Rui Baltazar', 'Mauricio Novelo'] + s.summary = 'Database multitenancy for Rack/Rails applications via ActiveRecord' + s.description = 'Apartment provides multitenancy for Rails and Rack applications ' \ + 'through schema-based or database-based isolation strategies.' + s.email = ['ryan@influitive.com', 'brad@influitive.com', 'rui.p.baltazar@gmail.com', 'mauricio@campusesp.com'] + + s.files = %w[ros-apartment.gemspec README.md] + `git ls-files -- lib`.split("\n") s.require_paths = ['lib'] s.homepage = 'https://github.com/rails-on-services/apartment' s.licenses = ['MIT'] + s.metadata = { + 'github_repo' => 'ssh://github.com/rails-on-services/apartment', + 'rubygems_mfa_required' => 'true', + } - s.add_dependency 'activerecord', '>= 5.0.0', '< 6.2' - s.add_dependency 'parallel', '< 2.0' - s.add_dependency 'public_suffix', '>= 2.0.5', '< 5.0' - s.add_dependency 'rack', '>= 1.3.6', '< 3.0' - - s.add_development_dependency 'appraisal', '~> 2.2' - s.add_development_dependency 'bundler', '>= 1.3', '< 3.0' - s.add_development_dependency 'capybara', '~> 2.0' - s.add_development_dependency 'rake', '~> 13.0' - s.add_development_dependency 'rspec', '~> 3.4' - s.add_development_dependency 'rspec-rails', '~> 3.4' + s.required_ruby_version = '>= 3.3' - if defined?(JRUBY_VERSION) - s.add_development_dependency 'activerecord-jdbc-adapter' - s.add_development_dependency 'activerecord-jdbcmysql-adapter' - s.add_development_dependency 'activerecord-jdbcpostgresql-adapter' - s.add_development_dependency 'jdbc-mysql' - s.add_development_dependency 'jdbc-postgres' - else - s.add_development_dependency 'mysql2', '~> 0.5' - s.add_development_dependency 'pg', '~> 1.2' - s.add_development_dependency 'sqlite3', '~> 1.3.6' - end + s.add_dependency('activerecord', '>= 7.2.0', '< 8.2') + s.add_dependency('activesupport', '>= 7.2.0', '< 8.2') + s.add_dependency('concurrent-ruby', '>= 1.3.0') + s.add_dependency('parallel', '>= 1.26.0') + s.add_dependency('public_suffix', '>= 2.0.5', '< 7') + s.add_dependency('rack', '>= 3.0.9', '< 4.0') + s.add_dependency('thor', '>= 1.3.0') + s.add_dependency('zeitwerk', '>= 2.7.1') end diff --git a/spec/CLAUDE.md b/spec/CLAUDE.md new file mode 100644 index 00000000..7b8aaefa --- /dev/null +++ b/spec/CLAUDE.md @@ -0,0 +1,287 @@ +# spec/ - Apartment Test Suite + +> **Note**: v4 unit tests live in `spec/unit/` (585 specs). v4 integration tests live in `spec/integration/v4/` (39 specs across SQLite/PostgreSQL/MySQL: switching, lifecycle, excluded models, edge cases, stress/concurrency, PG schemas, MySQL databases). Request lifecycle tests in `spec/integration/v4/request_lifecycle_spec.rb` exercise the full elevator-to-response flow through a dummy Rails app. Scenario-based YAML configs in `spec/integration/v4/scenarios/` define per-engine database settings. Integration tests use a ConnectionHandler swap for hermetic isolation (no cross-test pool leakage). Coverage via SimpleCov (opt-in: `COVERAGE=1`) and profiling via TestProf (`FPROF=1`, `EVENT_PROF=`). Run unit tests with `bundle exec rspec spec/unit/`. Run integration tests with `bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/` (SQLite), `DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/` (PG), or `DATABASE_ENGINE=mysql bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/` (MySQL). + +This directory contains the test suite for Apartment, covering adapters, elevators, configuration, and integration scenarios. + +## Directory Structure + +``` +spec/ +├── 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/ +│ └── v4/ # Full-stack integration tests +│ └── scenarios/ # YAML scenario configs (postgresql_schema, postgresql_database, mysql_database, sqlite_file) +├── schemas/ # Test schema fixtures +├── shared_examples/ # Reusable RSpec shared examples +├── support/ # Test helpers and configuration +├── unit/ # Unit tests (elevators, adapters, config, tenant_name_validator) +├── 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/unit/) + +**Purpose**: Test database-specific tenant operations (unit level, no real DB required) + +**Files** (under `spec/unit/`): +- `adapters/abstract_adapter_spec.rb` - Shared adapter behavior, callbacks, lifecycle +- `adapters/postgresql_schema_adapter_spec.rb` - PostgreSQL schema isolation +- `adapters/postgresql_database_adapter_spec.rb` - PostgreSQL database isolation +- `adapters/mysql2_adapter_spec.rb` - MySQL database isolation +- `adapters/sqlite3_adapter_spec.rb` - SQLite file isolation +- `concerns/model_spec.rb` - Apartment::Model concern (pin_tenant, apartment_pinned?, inheritance) +- `patches/connection_handling_spec.rb` - ConnectionHandling patch (tenant pool routing, pinned model bypass, role keying) + +**Integration adapter tests**: `spec/integration/v4/` (requires real databases) + +**See**: `spec/unit/` for unit test implementations, `spec/integration/v4/` for full-stack tests. + +### Elevator Tests (spec/unit/elevators/) + +**Purpose**: Test Rack middleware tenant detection (7 spec files, v4 constructor keyword args) + +**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 +- `header_spec.rb` - HTTP header-based switching (new in v4) + +**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/v4/` 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. + +### Coverage (SimpleCov) + +Opt-in via `COVERAGE=1 bundle exec rspec spec/unit/`. Reports to `coverage/` (gitignored). Groups: Adapters, Patches, Config, Core. Minimum 80%. + +### Profiling (TestProf) + +`FPROF=1` for flamegraph profiling. `EVENT_PROF=sql.active_record` for SQL event profiling. No `let_it_be`/`before_all` usage yet — add where profiling shows benefit. + +### 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/unit/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/unit/` and `spec/integration/v4/` 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 (tested in v4 via CurrentAttributes) +- ✅ Request lifecycle (elevator->switch->response in dummy app) +- ✅ RBAC integration (role-aware connections, privilege grants, Migrator with migration_role) + +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 deleted file mode 100644 index 2d0fb975..00000000 --- a/spec/adapters/jdbc_mysql_adapter_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -if defined?(JRUBY_VERSION) - - require 'spec_helper' - require 'apartment/adapters/jdbc_mysql_adapter' - - describe Apartment::Adapters::JDBCMysqlAdapter, database: :mysql do - subject { Apartment::Tenant.jdbc_mysql_adapter config.symbolize_keys } - - def tenant_names - ActiveRecord::Base.connection.execute('SELECT schema_name FROM information_schema.schemata').collect do |row| - row['schema_name'] - end - end - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } - - it_should_behave_like 'a generic apartment adapter callbacks' - it_should_behave_like 'a generic apartment adapter' - it_should_behave_like 'a connection based apartment adapter' - end -end diff --git a/spec/adapters/jdbc_postgresql_adapter_spec.rb b/spec/adapters/jdbc_postgresql_adapter_spec.rb deleted file mode 100644 index 67d3c981..00000000 --- a/spec/adapters/jdbc_postgresql_adapter_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -if defined?(JRUBY_VERSION) - - require 'spec_helper' - require 'apartment/adapters/jdbc_postgresql_adapter' - - describe Apartment::Adapters::JDBCPostgresqlAdapter, database: :postgresql do - subject { Apartment::Tenant.jdbc_postgresql_adapter config.symbolize_keys } - - it_should_behave_like 'a generic apartment adapter callbacks' - - context 'using schemas' do - before { Apartment.use_schemas = true } - - # 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'] } - end - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.gsub('"', '') } } - - it_should_behave_like 'a generic apartment adapter' - it_should_behave_like 'a schema based apartment adapter' - end - - context 'using databases' do - before { Apartment.use_schemas = false } - - # 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'] } - end - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } - - it_should_behave_like 'a generic apartment adapter' - it_should_behave_like 'a connection based apartment adapter' - end - end -end diff --git a/spec/adapters/mysql2_adapter_spec.rb b/spec/adapters/mysql2_adapter_spec.rb deleted file mode 100644 index 505b7d6b..00000000 --- a/spec/adapters/mysql2_adapter_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'apartment/adapters/mysql2_adapter' - -describe Apartment::Adapters::Mysql2Adapter, database: :mysql do - unless defined?(JRUBY_VERSION) - - subject(:adapter) { Apartment::Tenant.mysql2_adapter config } - - def tenant_names - ActiveRecord::Base.connection.execute('SELECT schema_name FROM information_schema.schemata').collect do |row| - row[0] - end - end - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } - - it_should_behave_like 'a generic apartment adapter callbacks' - - context 'using - the equivalent of - schemas' do - before { Apartment.use_schemas = true } - - it_should_behave_like 'a generic apartment adapter' - - describe '#default_tenant' do - it 'is set to the original db from config' do - expect(subject.default_tenant).to eq(config[:database]) - end - end - - describe '#init' do - include Apartment::Spec::AdapterRequirements - - before do - Apartment.configure do |config| - config.excluded_models = ['Company'] - end - end - - after do - # 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 'should process model exclusions' do - Apartment::Tenant.init - - expect(Company.table_name).to eq("#{default_tenant}.companies") - end - end - end - - context 'using connections' do - before { Apartment.use_schemas = false } - - it_should_behave_like 'a generic apartment adapter' - it_should_behave_like 'a generic apartment adapter able to handle custom configuration' - it_should_behave_like 'a connection based apartment adapter' - end - end -end diff --git a/spec/adapters/postgresql_adapter_spec.rb b/spec/adapters/postgresql_adapter_spec.rb deleted file mode 100644 index d7689d26..00000000 --- a/spec/adapters/postgresql_adapter_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'apartment/adapters/postgresql_adapter' - -describe Apartment::Adapters::PostgresqlAdapter, database: :postgresql do - unless defined?(JRUBY_VERSION) - - subject { Apartment::Tenant.postgresql_adapter config } - - it_should_behave_like 'a generic apartment adapter callbacks' - - context 'using schemas with schema.rb' do - before { Apartment.use_schemas = true } - - # 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'] } - end - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.gsub('"', '') } } - - it_should_behave_like 'a generic apartment adapter' - it_should_behave_like 'a schema based apartment adapter' - end - - context 'using schemas with SQL dump' do - before do - Apartment.use_schemas = true - Apartment.use_sql = true - 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'] } - end - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.gsub('"', '') } } - - it_should_behave_like 'a generic apartment adapter' - it_should_behave_like 'a schema based apartment adapter' - - it 'allows for dashes in the schema name' do - expect { Apartment::Tenant.create('has-dashes') }.to_not raise_error - end - - after do - Apartment::Tenant.drop('has-dashes') if Apartment.connection.schema_exists? 'has-dashes' - end - end - - context 'using connections' do - before { Apartment.use_schemas = false } - - # 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'] } - end - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } - - it_should_behave_like 'a generic apartment adapter' - it_should_behave_like 'a generic apartment adapter able to handle custom configuration' - it_should_behave_like 'a connection based apartment adapter' - end - end -end diff --git a/spec/adapters/sqlite3_adapter_spec.rb b/spec/adapters/sqlite3_adapter_spec.rb deleted file mode 100644 index f05c76ba..00000000 --- a/spec/adapters/sqlite3_adapter_spec.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'apartment/adapters/sqlite3_adapter' - -describe Apartment::Adapters::Sqlite3Adapter, database: :sqlite do - unless defined?(JRUBY_VERSION) - - subject { Apartment::Tenant.sqlite3_adapter config } - - it_should_behave_like 'a generic apartment adapter callbacks' - - context 'using connections' do - def tenant_names - db_dir = File.expand_path('../dummy/db', __dir__) - Dir.glob("#{db_dir}/*.sqlite3").map { |file| File.basename(file, '.sqlite3') } - end - - let(:default_tenant) do - subject.switch { File.basename(Apartment::Test.config['connections']['sqlite']['database'], '.sqlite3') } - end - - it_should_behave_like 'a generic apartment adapter' - it_should_behave_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 - config.append_environment = false - end - end - - after do - begin - subject.drop db_name - rescue StandardError => _e - nil - end - end - - it 'should create a new database' do - subject.create db_name - - expect(File.exist?("#{default_dir}/#{Rails.env}_#{db_name}.sqlite3")).to eq 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 - begin - subject.drop db_name - rescue StandardError => _e - nil - end - end - - it 'should create a new database' do - subject.create db_name - - expect(File.exist?("#{default_dir}/#{db_name}.sqlite3")).to eq true - end - end - - describe '#append' do - let(:db_name) { 'db_with_suffix' } - before do - Apartment.configure do |config| - config.prepend_environment = false - config.append_environment = true - end - end - - after do - begin - subject.drop db_name - rescue StandardError => _e - nil - end - end - - it 'should create a new database' do - subject.create db_name - - expect(File.exist?("#{default_dir}/#{db_name}_#{Rails.env}.sqlite3")).to eq true - end - end - end - end -end diff --git a/spec/apartment_spec.rb b/spec/apartment_spec.rb deleted file mode 100644 index f90a09f2..00000000 --- a/spec/apartment_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Apartment do - it 'should be valid' do - expect(Apartment).to be_a(Module) - end - - it 'should be a valid app' do - expect(::Rails.application).to be_a(Dummy::Application) - end -end diff --git a/spec/config/database.yml.sample b/spec/config/database.yml.sample deleted file mode 100644 index e59c63a4..00000000 --- a/spec/config/database.yml.sample +++ /dev/null @@ -1,49 +0,0 @@ -<% if defined?(JRUBY_VERSION) %> -connections: - postgresql: - adapter: postgresql - database: apartment_postgresql_test - username: postgres - min_messages: WARNING - driver: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/apartment_postgresql_test - timeout: 5000 - pool: 5 - host: localhost - port: 5432 - - mysql: - adapter: mysql - database: apartment_mysql_test - username: root - min_messages: WARNING - driver: com.mysql.jdbc.Driver - url: jdbc:mysql://localhost:3306/apartment_mysql_test - timeout: 5000 - pool: 5 - host: 127.0.0.1 - port: 3306 -<% else %> -connections: - postgresql: - adapter: postgresql - database: apartment_postgresql_test - min_messages: WARNING - username: postgres - schema_search_path: public - password: - host: localhost - port: 5432 - - mysql: - adapter: mysql2 - database: apartment_mysql_test - username: root - password: - host: 127.0.0.1 - port: 3306 - - sqlite: - adapter: sqlite3 - database: <%= File.expand_path('../spec/dummy/db', __FILE__) %>/default.sqlite3 -<% end %> diff --git a/spec/config/mysql.yml.erb b/spec/config/mysql.yml.erb new file mode 100644 index 00000000..8ba107e9 --- /dev/null +++ b/spec/config/mysql.yml.erb @@ -0,0 +1,14 @@ +connections: + mysql: + adapter: mysql2 + database: apartment_mysql_test + username: root + min_messages: WARNING + host: 127.0.0.1 + port: 3306 +<% if defined?(JRUBY_VERSION) %> + driver: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/apartment_mysql_test + timeout: 5000 + pool: 5 +<% end %> diff --git a/spec/config/postgresql.yml.erb b/spec/config/postgresql.yml.erb new file mode 100644 index 00000000..16fea028 --- /dev/null +++ b/spec/config/postgresql.yml.erb @@ -0,0 +1,17 @@ +connections: + postgresql: + adapter: postgresql + database: apartment_postgresql_test + username: postgres + min_messages: WARNING + host: localhost + port: 5432 +<% if defined?(JRUBY_VERSION) %> + driver: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/apartment_postgresql_test + timeout: 5000 + pool: 5 +<% else %> + schema_search_path: public + password: +<% end %> diff --git a/spec/config/sqlite.yml.erb b/spec/config/sqlite.yml.erb new file mode 100644 index 00000000..a8d41297 --- /dev/null +++ b/spec/config/sqlite.yml.erb @@ -0,0 +1,6 @@ +<% unless defined?(JRUBY_VERSION) %> +connections: + sqlite: + adapter: sqlite3 + database: <%= File.expand_path('../spec/dummy/db', __FILE__) %>/test.sqlite3 +<% end %> diff --git a/spec/dummy/app/controllers/tenants_controller.rb b/spec/dummy/app/controllers/tenants_controller.rb new file mode 100644 index 00000000..beb89f17 --- /dev/null +++ b/spec/dummy/app/controllers/tenants_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TenantsController < ApplicationController + def show + render(json: { + tenant: Apartment::Tenant.current, + user_count: User.count, + }) + end +end diff --git a/spec/dummy/app/models/user_with_tenant_model.rb b/spec/dummy/app/models/user_with_tenant_model.rb deleted file mode 100644 index 9871d748..00000000 --- a/spec/dummy/app/models/user_with_tenant_model.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -require 'apartment/model' - -class UserWithTenantModel < ApplicationRecord - include Apartment::Model - - self.table_name = 'users' - # Dummy models -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..dc886358 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -5,47 +5,18 @@ require 'active_model/railtie' require 'active_record/railtie' require 'action_controller/railtie' -require 'action_view/railtie' -require 'action_mailer/railtie' Bundler.require require 'apartment' module Dummy class Application < Rails::Application - # Settings in config/environments/* take precedence over those specified here. - # Application configuration should go into files in config/initializers - # -- all .rb files in that directory are automatically loaded. - require 'apartment/elevators/subdomain' - require 'apartment/elevators/domain' - - config.middleware.use Apartment::Elevators::Subdomain - - # Custom directories with classes and modules you want to be autoloadable. - config.autoload_paths += %W[#{config.root}/lib] - - # Only load the plugins named here, in the order given (default is alphabetical). - # :all can be used as a placeholder for all plugins not explicitly named. - # config.plugins = [ :exception_notification, :ssl_requirement, :all ] - - # Activate observers that should always be running. - # config.active_record.observers = :cacher, :garbage_collector, :forum_observer - - # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. - # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. - # config.time_zone = 'Central Time (US & Canada)' - - # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. - # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] - # config.i18n.default_locale = :de - - # JavaScript files you want as :defaults (application.js is always included). - # config.action_view.javascript_expansions[:defaults] = %w(jquery rails) - - # Configure the default encoding used in templates for Ruby 1.9. + config.load_defaults(Rails::VERSION::STRING.to_f) + config.eager_load = false config.encoding = 'utf-8' - - # Configure sensitive parameters which will be filtered from the log file. config.filter_parameters += [:password] + + # v4 Railtie handles middleware insertion and Apartment.activate! + # No manual middleware.use needed. 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/database.yml.sample b/spec/dummy/config/database.yml.sample deleted file mode 100644 index 09067020..00000000 --- a/spec/dummy/config/database.yml.sample +++ /dev/null @@ -1,44 +0,0 @@ -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. -<% if defined?(JRUBY_VERSION) %> -test: - adapter: postgresql - database: apartment_postgresql_test - username: postgres - min_messages: WARNING - driver: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/apartment_postgresql_test - timeout: 5000 - pool: 5 - -development: - adapter: postgresql - database: apartment_postgresql_development - username: postgres - min_messages: WARNING - driver: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/apartment_postgresql_development - timeout: 5000 - pool: 5 -<% else %> -test: - adapter: postgresql - database: apartment_postgresql_test - username: postgres - min_messages: WARNING - pool: 5 - timeout: 5000 - host: localhost - port: 5432 - -development: - adapter: postgresql - database: apartment_postgresql_development - username: postgres - min_messages: WARNING - pool: 5 - timeout: 5000 - host: localhost - port: 5432 -<% end %> diff --git a/spec/dummy/config/environments/test.rb b/spec/dummy/config/environments/test.rb index 3044c445..4b0bd2f9 100644 --- a/spec/dummy/config/environments/test.rb +++ b/spec/dummy/config/environments/test.rb @@ -1,36 +1,10 @@ # frozen_string_literal: true Dummy::Application.configure do - # Settings specified here will take precedence over those in config/application.rb - - # The test environment is used exclusively to run your application's - # test suite. You never need to work with it otherwise. Remember that - # your test database is "scratch space" for the test suite and is wiped - # and recreated between test runs. Don't rely on the data there! config.cache_classes = true - config.eager_load = false - - # Show full error reports and disable caching - config.consider_all_requests_local = true + config.consider_all_requests_local = true config.action_controller.perform_caching = false - - # Raise exceptions instead of rendering exception templates - config.action_dispatch.show_exceptions = false - - # Disable request forgery protection in test environment - config.action_controller.allow_forgery_protection = false - - # Tell Action Mailer not to deliver emails to the real world. - # The :test delivery method accumulates sent emails in the - # ActionMailer::Base.deliveries array. - config.action_mailer.delivery_method = :test - - # Use SQL instead of Active Record's schema dumper when creating the test database. - # This is necessary if your schema can't be completely dumped by the schema dumper, - # like if you have constraints or database-specific column types - # config.active_record.schema_format = :sql - - # Print deprecation notices to the stderr + config.action_dispatch.show_exceptions = :none config.active_support.deprecation = :stderr end diff --git a/spec/dummy/config/initializers/apartment.rb b/spec/dummy/config/initializers/apartment.rb index 7db9fd26..c8ffa097 100644 --- a/spec/dummy/config/initializers/apartment.rb +++ b/spec/dummy/config/initializers/apartment.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { Company.pluck(:database) } + config.default_tenant = 'public' config.excluded_models = ['Company'] - config.tenant_names = -> { Company.pluck(:database) } + config.elevator = :subdomain + config.schema_load_strategy = nil # dummy app manages its own schema + config.configure_postgres do |pg| + pg.persistent_schemas = %w[public] + end end 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/config/routes.rb b/spec/dummy/config/routes.rb index d0be0e6d..ccc8f571 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true Dummy::Application.routes.draw do - root to: 'application#index' + get '/tenant_info' => 'tenants#show' + root to: 'tenants#show' end diff --git a/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb b/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb index 1da7c0c7..2514bc7a 100644 --- a/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb +++ b/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb @@ -1,39 +1,38 @@ # frozen_string_literal: true -migration_class = ActiveRecord::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration -class CreateDummyModels < migration_class +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 d525063d..8133ab4f 100644 --- a/spec/dummy/db/migrate/20111202022214_create_table_books.rb +++ b/spec/dummy/db/migrate/20111202022214_create_table_books.rb @@ -1,16 +1,15 @@ # frozen_string_literal: true -migration_class = ActiveRecord::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration -class CreateTableBooks < migration_class +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 24e27249..cc2e0bb6 100644 --- a/spec/dummy/db/migrate/20180415260934_create_public_tokens.rb +++ b/spec/dummy/db/migrate/20180415260934_create_public_tokens.rb @@ -1,15 +1,14 @@ # frozen_string_literal: true -migration_class = ActiveRecord::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration -class CreatePublicTokens < migration_class +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/db/test.sqlite3 b/spec/dummy/db/test.sqlite3 deleted file mode 100644 index 50ec17fa..00000000 Binary files a/spec/dummy/db/test.sqlite3 and /dev/null differ diff --git a/spec/dummy_engine/.gitignore b/spec/dummy_engine/.gitignore deleted file mode 100644 index de5d954f..00000000 --- a/spec/dummy_engine/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.bundle/ -log/*.log -pkg/ -test/dummy/db/*.sqlite3 -test/dummy/db/*.sqlite3-journal -test/dummy/log/*.log -test/dummy/tmp/ -test/dummy/.sass-cache diff --git a/spec/dummy_engine/Gemfile b/spec/dummy_engine/Gemfile deleted file mode 100644 index 59f9baba..00000000 --- a/spec/dummy_engine/Gemfile +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -source 'https://rubygems.org' - -# Declare your gem's dependencies in dummy_engine.gemspec. -# Bundler will treat runtime dependencies like base dependencies, and -# development dependencies will be added by default to the :development group. -gemspec - -# Declare any dependencies that are still in development here instead of in -# your gemspec. These might include edge Rails or gems from your path or -# Git. Remember to move these dependencies to your gemspec before releasing -# your gem to rubygems.org. - -# To use debugger -# gem 'debugger' -gem 'apartment', path: '../../' diff --git a/spec/dummy_engine/Rakefile b/spec/dummy_engine/Rakefile deleted file mode 100644 index 72a61a74..00000000 --- a/spec/dummy_engine/Rakefile +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -begin - require 'bundler/setup' -rescue LoadError - puts 'You must `gem install bundler` and `bundle install` to run rake tasks' -end - -require 'rdoc/task' - -RDoc::Task.new(:rdoc) do |rdoc| - rdoc.rdoc_dir = 'rdoc' - rdoc.title = 'DummyEngine' - rdoc.options << '--line-numbers' - rdoc.rdoc_files.include('README.rdoc') - rdoc.rdoc_files.include('lib/**/*.rb') -end - -APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__) -load 'rails/tasks/engine.rake' - -Bundler::GemHelper.install_tasks - -require 'rake/testtask' - -Rake::TestTask.new(:test) do |t| - t.libs << 'lib' - t.libs << 'test' - t.pattern = 'test/**/*_test.rb' - t.verbose = false -end - -task default: :test diff --git a/spec/dummy_engine/bin/rails b/spec/dummy_engine/bin/rails deleted file mode 100755 index 397f409e..00000000 --- a/spec/dummy_engine/bin/rails +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# This command will automatically be run when you run "rails" with Rails 4 gems installed -# from the root of your application. - -ENGINE_ROOT = File.expand_path('..', __dir__) -ENGINE_PATH = File.expand_path('../lib/dummy_engine/engine', __dir__) - -# Set up gems listed in the Gemfile. -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) -require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) - -require 'rails/all' -require 'rails/engine/commands' diff --git a/spec/dummy_engine/config/initializers/apartment.rb b/spec/dummy_engine/config/initializers/apartment.rb deleted file mode 100644 index a65748eb..00000000 --- a/spec/dummy_engine/config/initializers/apartment.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -# Require whichever elevator you're using below here... -# -# require 'apartment/elevators/generic' -# require 'apartment/elevators/domain' -require 'apartment/elevators/subdomain' - -# -# Apartment Configuration -# -Apartment.configure do |config| - # These models will not be multi-tenanted, - # but remain in the global (public) namespace - # - # An example might be a Customer or Tenant model that stores each tenant information - # ex: - # - # config.excluded_models = %w{Tenant} - # - config.excluded_models = %w[] - - # use postgres schemas? - config.use_schemas = true - - # use raw SQL dumps for creating postgres schemas? (only appies with use_schemas set to true) - # config.use_sql = true - - # configure persistent schemas (E.g. hstore ) - # config.persistent_schemas = %w{ hstore } - - # add the Rails environment to database names? - # config.prepend_environment = true - # config.append_environment = true - - # supply list of database names for migrations to run on - # config.tenant_names = lambda{ ToDo_Tenant_Or_User_Model.pluck :database } - - # Specify a connection other than ActiveRecord::Base for apartment to use - # (only needed if your models are using a different connection) - # config.connection_class = ActiveRecord::Base -end - -## -# Elevator Configuration - -# Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request| -# # TODO: supply generic implementation -# } - -# Rails.application.config.middleware.use Apartment::Elevators::Domain - -Rails.application.config.middleware.use Apartment::Elevators::Subdomain diff --git a/spec/dummy_engine/dummy_engine.gemspec b/spec/dummy_engine/dummy_engine.gemspec deleted file mode 100644 index afb00e8f..00000000 --- a/spec/dummy_engine/dummy_engine.gemspec +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -$LOAD_PATH << File.expand_path('lib', __dir__) - -# Maintain your gem's version: -require 'dummy_engine/version' - -# Describe your gem and declare its dependencies: -Gem::Specification.new do |s| - s.name = 'dummy_engine' - s.version = DummyEngine::VERSION - s.authors = ['Your name'] - s.email = ['Your email'] - s.homepage = '' - s.summary = 'Summary of DummyEngine.' - s.description = 'Description of DummyEngine.' - s.license = 'MIT' - - s.files = Dir['{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.rdoc'] - s.test_files = Dir['test/**/*'] - - s.add_dependency 'apartment' - s.add_dependency 'rails', '~> 4.1.6' - - s.add_development_dependency 'sqlite3' -end diff --git a/spec/dummy_engine/lib/dummy_engine.rb b/spec/dummy_engine/lib/dummy_engine.rb deleted file mode 100644 index 8f9c8111..00000000 --- a/spec/dummy_engine/lib/dummy_engine.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'dummy_engine/engine' - -module DummyEngine -end diff --git a/spec/dummy_engine/lib/dummy_engine/engine.rb b/spec/dummy_engine/lib/dummy_engine/engine.rb deleted file mode 100644 index d308ec0d..00000000 --- a/spec/dummy_engine/lib/dummy_engine/engine.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module DummyEngine - class Engine < ::Rails::Engine - end -end diff --git a/spec/dummy_engine/lib/dummy_engine/version.rb b/spec/dummy_engine/lib/dummy_engine/version.rb deleted file mode 100644 index 76d025df..00000000 --- a/spec/dummy_engine/lib/dummy_engine/version.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -module DummyEngine - VERSION = '0.0.1' -end diff --git a/spec/dummy_engine/test/dummy/Rakefile b/spec/dummy_engine/test/dummy/Rakefile deleted file mode 100644 index e51cf0e1..00000000 --- a/spec/dummy_engine/test/dummy/Rakefile +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -# Add your own tasks in files placed in lib/tasks ending in .rake, -# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. - -require File.expand_path('config/application', __dir__) - -Rails.application.load_tasks diff --git a/spec/dummy_engine/test/dummy/config.ru b/spec/dummy_engine/test/dummy/config.ru deleted file mode 100644 index 667e328d..00000000 --- a/spec/dummy_engine/test/dummy/config.ru +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -# This file is used by Rack-based servers to start the application. - -require ::File.expand_path('config/environment', __dir__) -run Rails.application diff --git a/spec/dummy_engine/test/dummy/config/application.rb b/spec/dummy_engine/test/dummy/config/application.rb deleted file mode 100644 index 0984f6ce..00000000 --- a/spec/dummy_engine/test/dummy/config/application.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require File.expand_path('boot', __dir__) - -require 'rails/all' - -Bundler.require(*Rails.groups) -require 'dummy_engine' - -module Dummy - class Application < Rails::Application - # Settings in config/environments/* take precedence over those specified here. - # Application configuration should go into files in config/initializers - # -- all .rb files in that directory are automatically loaded. - - # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. - # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. - # config.time_zone = 'Central Time (US & Canada)' - - # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. - # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] - # config.i18n.default_locale = :de - end -end diff --git a/spec/dummy_engine/test/dummy/config/boot.rb b/spec/dummy_engine/test/dummy/config/boot.rb deleted file mode 100644 index 6d2cba07..00000000 --- a/spec/dummy_engine/test/dummy/config/boot.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# Set up gems listed in the Gemfile. -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__) diff --git a/spec/dummy_engine/test/dummy/config/database.yml b/spec/dummy_engine/test/dummy/config/database.yml deleted file mode 100644 index 1c1a37ca..00000000 --- a/spec/dummy_engine/test/dummy/config/database.yml +++ /dev/null @@ -1,25 +0,0 @@ -# SQLite version 3.x -# gem install sqlite3 -# -# Ensure the SQLite 3 gem is defined in your Gemfile -# gem 'sqlite3' -# -default: &default - adapter: sqlite3 - pool: 5 - timeout: 5000 - -development: - <<: *default - database: db/development.sqlite3 - -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. -test: - <<: *default - database: db/test.sqlite3 - -production: - <<: *default - database: db/production.sqlite3 diff --git a/spec/dummy_engine/test/dummy/config/environment.rb b/spec/dummy_engine/test/dummy/config/environment.rb deleted file mode 100644 index 32d57aa4..00000000 --- a/spec/dummy_engine/test/dummy/config/environment.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# Load the Rails application. -require File.expand_path('application', __dir__) - -# Initialize the Rails application. -Rails.application.initialize! diff --git a/spec/dummy_engine/test/dummy/config/environments/development.rb b/spec/dummy_engine/test/dummy/config/environments/development.rb deleted file mode 100644 index 8296624e..00000000 --- a/spec/dummy_engine/test/dummy/config/environments/development.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # In the development environment your application's code is reloaded on - # every request. This slows down response time but is perfect for development - # since you don't have to restart the web server when you make code changes. - config.cache_classes = false - - # Do not eager load code on boot. - config.eager_load = false - - # Show full error reports and disable caching. - config.consider_all_requests_local = true - config.action_controller.perform_caching = false - - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false - - # Print deprecation notices to the Rails logger. - config.active_support.deprecation = :log - - # Raise an error on page load if there are pending migrations. - config.active_record.migration_error = :page_load - - # Debug mode disables concatenation and preprocessing of assets. - # This option may cause significant delays in view rendering with a large - # number of complex assets. - config.assets.debug = true - - # Adds additional error checking when serving assets at runtime. - # Checks for improperly declared sprockets dependencies. - # Raises helpful error messages. - config.assets.raise_runtime_errors = true - - # Raises error for missing translations - # config.action_view.raise_on_missing_translations = true -end diff --git a/spec/dummy_engine/test/dummy/config/environments/production.rb b/spec/dummy_engine/test/dummy/config/environments/production.rb deleted file mode 100644 index 1bd152f1..00000000 --- a/spec/dummy_engine/test/dummy/config/environments/production.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # Code is not reloaded between requests. - config.cache_classes = true - - # Eager load code on boot. This eager loads most of Rails and - # your application in memory, allowing both threaded web servers - # and those relying on copy on write to perform better. - # Rake tasks automatically ignore this option for performance. - config.eager_load = true - - # Full error reports are disabled and caching is turned on. - config.consider_all_requests_local = false - config.action_controller.perform_caching = true - - # Enable Rack::Cache to put a simple HTTP cache in front of your application - # Add `rack-cache` to your Gemfile before enabling this. - # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid. - # config.action_dispatch.rack_cache = true - - # Disable Rails's static asset server (Apache or nginx will already do this). - config.serve_static_assets = false - - # Compress JavaScripts and CSS. - config.assets.js_compressor = :uglifier - # config.assets.css_compressor = :sass - - # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false - - # Generate digests for assets URLs. - config.assets.digest = true - - # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb - - # Specifies the header that your server uses for sending files. - # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache - # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx - - # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - # config.force_ssl = true - - # Set to :debug to see everything in the log. - config.log_level = :info - - # Prepend all log lines with the following tags. - # config.log_tags = [ :subdomain, :uuid ] - - # Use a different logger for distributed setups. - # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) - - # Use a different cache store in production. - # config.cache_store = :mem_cache_store - - # Enable serving of images, stylesheets, and JavaScripts from an asset server. - # config.action_controller.asset_host = "http://assets.example.com" - - # Ignore bad email addresses and do not raise email delivery errors. - # Set this to true and configure the email server for immediate delivery to raise delivery errors. - # config.action_mailer.raise_delivery_errors = false - - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation cannot be found). - config.i18n.fallbacks = true - - # Send deprecation notices to registered listeners. - config.active_support.deprecation = :notify - - # Disable automatic flushing of the log to improve performance. - # config.autoflush_log = false - - # Use default logging formatter so that PID and timestamp are not suppressed. - config.log_formatter = ::Logger::Formatter.new - - # Do not dump schema after migrations. - config.active_record.dump_schema_after_migration = false -end diff --git a/spec/dummy_engine/test/dummy/config/environments/test.rb b/spec/dummy_engine/test/dummy/config/environments/test.rb deleted file mode 100644 index bd942389..00000000 --- a/spec/dummy_engine/test/dummy/config/environments/test.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # The test environment is used exclusively to run your application's - # test suite. You never need to work with it otherwise. Remember that - # your test database is "scratch space" for the test suite and is wiped - # and recreated between test runs. Don't rely on the data there! - config.cache_classes = true - - # Do not eager load code on boot. This avoids loading your whole application - # just for the purpose of running a single test. If you are using a tool that - # preloads Rails for running tests, you may have to set it to true. - config.eager_load = false - - # Configure static asset server for tests with Cache-Control for performance. - config.serve_static_assets = true - config.static_cache_control = 'public, max-age=3600' - - # Show full error reports and disable caching. - config.consider_all_requests_local = true - config.action_controller.perform_caching = false - - # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = false - - # Disable request forgery protection in test environment. - config.action_controller.allow_forgery_protection = false - - # Tell Action Mailer not to deliver emails to the real world. - # The :test delivery method accumulates sent emails in the - # ActionMailer::Base.deliveries array. - config.action_mailer.delivery_method = :test - - # Print deprecation notices to the stderr. - config.active_support.deprecation = :stderr - - # Raises error for missing translations - # config.action_view.raise_on_missing_translations = true -end diff --git a/spec/dummy_engine/test/dummy/config/initializers/assets.rb b/spec/dummy_engine/test/dummy/config/initializers/assets.rb deleted file mode 100644 index 761905a7..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/assets.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -# Be sure to restart your server when you modify this file. - -# Version of your assets, change this if you want to expire all your assets. -Rails.application.config.assets.version = '1.0' - -# Precompile additional assets. -# application.js, application.css, and all non-JS/CSS in app/assets folder are already added. -# Rails.application.config.assets.precompile += %w( search.js ) diff --git a/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb b/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb deleted file mode 100644 index d0f0d3b5..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb +++ /dev/null @@ -1,8 +0,0 @@ -# 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. -# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } - -# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. -# Rails.backtrace_cleaner.remove_silencers! diff --git a/spec/dummy_engine/test/dummy/config/initializers/cookies_serializer.rb b/spec/dummy_engine/test/dummy/config/initializers/cookies_serializer.rb deleted file mode 100644 index 0a23b25e..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/cookies_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -# Be sure to restart your server when you modify this file. - -Rails.application.config.action_dispatch.cookies_serializer = :json diff --git a/spec/dummy_engine/test/dummy/config/initializers/filter_parameter_logging.rb b/spec/dummy_engine/test/dummy/config/initializers/filter_parameter_logging.rb deleted file mode 100644 index 7a4f47b4..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/filter_parameter_logging.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -# Be sure to restart your server when you modify this file. - -# Configure sensitive parameters which will be filtered from the log file. -Rails.application.config.filter_parameters += [:password] diff --git a/spec/dummy_engine/test/dummy/config/initializers/inflections.rb b/spec/dummy_engine/test/dummy/config/initializers/inflections.rb deleted file mode 100644 index aa7435fb..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/inflections.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -# Be sure to restart your server when you modify this file. - -# Add new inflection rules using the following format. Inflections -# are locale specific, and you may define rules for as many different -# locales as you wish. All of these examples are active by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, '\1en' -# inflect.singular /^(ox)en/i, '\1' -# inflect.irregular 'person', 'people' -# inflect.uncountable %w( fish sheep ) -# end - -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' -# end diff --git a/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb b/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb deleted file mode 100644 index 6e1d16f0..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb +++ /dev/null @@ -1,5 +0,0 @@ -# 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: -# Mime::Type.register "text/richtext", :rtf diff --git a/spec/dummy_engine/test/dummy/config/initializers/session_store.rb b/spec/dummy_engine/test/dummy/config/initializers/session_store.rb deleted file mode 100644 index 969d977f..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/session_store.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -# Be sure to restart your server when you modify this file. - -Rails.application.config.session_store :cookie_store, key: '_dummy_session' diff --git a/spec/dummy_engine/test/dummy/config/initializers/wrap_parameters.rb b/spec/dummy_engine/test/dummy/config/initializers/wrap_parameters.rb deleted file mode 100644 index 246168a4..00000000 --- a/spec/dummy_engine/test/dummy/config/initializers/wrap_parameters.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# Be sure to restart your server when you modify this file. - -# This file contains settings for ActionController::ParamsWrapper which -# is enabled by default. - -# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. -ActiveSupport.on_load(:action_controller) do - wrap_parameters format: [:json] if respond_to?(:wrap_parameters) -end - -# To enable root element in JSON for ActiveRecord objects. -# ActiveSupport.on_load(:active_record) do -# self.include_root_in_json = true -# end diff --git a/spec/dummy_engine/test/dummy/config/locales/en.yml b/spec/dummy_engine/test/dummy/config/locales/en.yml deleted file mode 100644 index 06539571..00000000 --- a/spec/dummy_engine/test/dummy/config/locales/en.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Files in the config/locales directory are used for internationalization -# and are automatically loaded by Rails. If you want to use locales other -# than English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t 'hello' -# -# In views, this is aliased to just `t`: -# -# <%= t('hello') %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# To learn more, please read the Rails Internationalization guide -# available at http://guides.rubyonrails.org/i18n.html. - -en: - hello: "Hello world" diff --git a/spec/dummy_engine/test/dummy/config/routes.rb b/spec/dummy_engine/test/dummy/config/routes.rb deleted file mode 100644 index 189947fc..00000000 --- a/spec/dummy_engine/test/dummy/config/routes.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -Rails.application.routes.draw do - # The priority is based upon order of creation: first created -> highest priority. - # See how all your routes lay out with "rake routes". - - # You can have the root of your site routed with "root" - # root 'welcome#index' - - # Example of regular route: - # get 'products/:id' => 'catalog#view' - - # Example of named route that can be invoked with purchase_url(id: product.id) - # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase - - # Example resource route (maps HTTP verbs to controller actions automatically): - # resources :products - - # Example resource route with options: - # resources :products do - # member do - # get 'short' - # post 'toggle' - # end - # - # collection do - # get 'sold' - # end - # end - - # Example resource route with sub-resources: - # resources :products do - # resources :comments, :sales - # resource :seller - # end - - # Example resource route with more complex sub-resources: - # resources :products do - # resources :comments - # resources :sales do - # get 'recent', on: :collection - # end - # end - - # Example resource route with concerns: - # concern :toggleable do - # post 'toggle' - # end - # resources :posts, concerns: :toggleable - # resources :photos, concerns: :toggleable - - # Example resource route within a namespace: - # namespace :admin do - # # Directs /admin/products/* to Admin::ProductsController - # # (app/controllers/admin/products_controller.rb) - # resources :products - # end -end diff --git a/spec/dummy_engine/test/dummy/config/secrets.yml b/spec/dummy_engine/test/dummy/config/secrets.yml deleted file mode 100644 index ee200137..00000000 --- a/spec/dummy_engine/test/dummy/config/secrets.yml +++ /dev/null @@ -1,22 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Your secret key is used for verifying the integrity of signed cookies. -# If you change this key, all old signed cookies will become invalid! - -# Make sure the secret is at least 30 characters and all random, -# no regular words or you'll be exposed to dictionary attacks. -# You can use `rake secret` to generate a secure secret key. - -# Make sure the secrets in this file are kept private -# if you're sharing your code publicly. - -development: - secret_key_base: bb62b819b585a74e69c797f9d03d5a004d8fe82a8e7a7da6fa2f7923030713b7b087c12cc7a918e71073c38afb343f7223d22ba3f1b223b7e76dbf8d5b65fa2c - -test: - secret_key_base: 67945d3b189c71dffef98de2bb7c14d6fb059679c115ca3cddf65c88babe130afe4d583560d0e308b017dd76ce305bef4159d876de9fd893952d9cbf269c8476 - -# Do not keep production secrets in the repository, -# instead read values from the environment. -production: - secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> diff --git a/spec/examples/connection_adapter_examples.rb b/spec/examples/connection_adapter_examples.rb deleted file mode 100644 index 973ed1fa..00000000 --- a/spec/examples/connection_adapter_examples.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -shared_examples_for 'a connection based apartment adapter' do - include Apartment::Spec::AdapterRequirements - - let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } - - describe '#init' do - after do - # 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 'should process 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) - end - end - - describe '#drop' do - it 'should raise an error for unknown database' do - expect do - 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 - expect do - 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 deleted file mode 100644 index 2ad7ee20..00000000 --- a/spec/examples/generic_adapter_custom_configuration_example.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -shared_examples_for 'a generic apartment adapter able to handle custom configuration' do - let(:custom_tenant_name) { 'test_tenantwwww' } - let(:db) { |example| example.metadata[:database] } - let(:custom_tenant_names) do - { - custom_tenant_name => custom_db_conf - } - end - - before do - Apartment.tenant_names = custom_tenant_names - Apartment.with_multi_server_setup = true - end - - after do - Apartment.with_multi_server_setup = false - end - - context 'database key taken from specific config' do - let(:expected_args) { custom_db_conf } - - describe '#create' do - it 'should establish_connection 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) - end - end - - describe '#drop' do - it 'should establish_connection 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) - end - end - end - - context 'database key from tenant name' do - let(:expected_args) do - custom_db_conf.tap { |args| args.delete(:database) } - end - - describe '#switch!' do - it 'should connect 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 - - # 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) - end - end - end - - def specific_connection - { - postgresql: { - adapter: 'postgresql', - database: 'override_database', - password: 'override_password', - username: 'overridepostgres' - }, - mysql: { - adapter: 'mysql2', - database: 'override_database', - username: 'root' - }, - sqlite: { - adapter: 'sqlite3', - database: 'override_database' - } - } - end - - def custom_db_conf - specific_connection[db.to_sym].with_indifferent_access - end -end diff --git a/spec/examples/generic_adapter_examples.rb b/spec/examples/generic_adapter_examples.rb deleted file mode 100644 index cbe6013f..00000000 --- a/spec/examples/generic_adapter_examples.rb +++ /dev/null @@ -1,179 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -shared_examples_for 'a generic apartment adapter' do - include Apartment::Spec::AdapterRequirements - - before do - Apartment.prepend_environment = false - Apartment.append_environment = false - Apartment.tenant_presence_check = true - end - - describe '#init' do - it 'should not retain a connection after railtie' do - ActiveRecord::Base.connection_pool.disconnect! - - 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 - - expect(num_available_connections).to eq(1) - end - - it 'should not connect if env var is set' do - ENV['APARTMENT_DISABLE_INIT'] = 'true' - begin - ActiveRecord::Base.connection_pool.disconnect! - - 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 - - expect(num_available_connections).to eq(0) - ensure - ENV.delete('APARTMENT_DISABLE_INIT') - end - end - end - - # - # 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) - end - - it 'should load schema.rb to new schema' do - subject.switch(db1) do - expect(connection.tables).to include('companies') - end - end - - it 'should yield 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) - User.create - end - - expect(subject.current).not_to eq(db2) - - 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 - next if Apartment.use_sql - - subject.drop(db1) - begin - Dir.mktmpdir do |tmpdir| - Apartment.database_schema_file = "#{tmpdir}/schema.rb" - expect do - subject.create(db1) - end.to raise_error(Apartment::FileNotFound) - end - ensure - Apartment.remove_instance_variable(:@database_schema_file) - end - end - end - - describe '#drop' do - it 'should remove 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 - subject.switch!(db1) - expect(subject.current).to eq(db1) - end - - it 'should reset connection if database is nil' do - subject.switch! - expect(subject.current).to eq(default_tenant) - end - - it 'should raise an error if database is invalid' do - expect do - subject.switch! 'unknown_database' - end.to raise_error(Apartment::ApartmentError) - end - end - - describe '#switch' do - it 'connects and resets the tenant' do - subject.switch(db1) do - expect(subject.current).to eq(db1) - end - 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 - subject.switch!(db2) - - expect do - subject.switch(db1) { subject.drop(db2) } - end.to_not raise_error - end - end - - describe '#reset' do - it 'should reset connection' do - subject.switch!(db1) - subject.reset - expect(subject.current).to eq(default_tenant) - end - end - - describe '#current' do - it 'should return the current db name' do - subject.switch!(db1) - expect(subject.current).to eq(db1) - end - end - - describe '#each' do - it 'iterates over each tenant by default' do - result = [] - Apartment.tenant_names = [db2, db1] - - subject.each do |tenant| - result << tenant - expect(subject.current).to eq(tenant) - end - - expect(result).to eq([db2, db1]) - end - - it 'iterates over the given tenants' do - result = [] - Apartment.tenant_names = [db2] - - subject.each([db2]) do |tenant| - result << tenant - expect(subject.current).to eq(tenant) - end - - 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 deleted file mode 100644 index 3184a4a1..00000000 --- a/spec/examples/generic_adapters_callbacks_examples.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -shared_examples_for 'a generic apartment adapter callbacks' do - # rubocop:disable Lint/ConstantDefinitionInBlock - class MyProc - def self.call(tenant_name); end - end - # rubocop:enable Lint/ConstantDefinitionInBlock - - include Apartment::Spec::AdapterRequirements - - before do - Apartment.prepend_environment = false - Apartment.append_environment = false - end - - describe '#switch!' do - before do - Apartment::Adapters::AbstractAdapter.set_callback :switch, :before do - MyProc.call(Apartment::Tenant.current) - end - - Apartment::Adapters::AbstractAdapter.set_callback :switch, :after do - MyProc.call(Apartment::Tenant.current) - end - - 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 - end - - context 'when tenant is nil' do - before do - Apartment::Tenant.switch!(nil) - end - - it 'runs both before and after callbacks' do - expect(MyProc).to have_received(:call).twice - end - end - - context 'when tenant is not nil' do - before do - Apartment::Tenant.switch!(db1) - end - - it 'runs both before and after callbacks' do - expect(MyProc).to have_received(:call).twice - end - end - end -end diff --git a/spec/examples/schema_adapter_examples.rb b/spec/examples/schema_adapter_examples.rb deleted file mode 100644 index 447322e3..00000000 --- a/spec/examples/schema_adapter_examples.rb +++ /dev/null @@ -1,254 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -shared_examples_for 'a schema based apartment adapter' do - include Apartment::Spec::AdapterRequirements - - let(:schema1) { db1 } - let(:schema2) { db2 } - let(:public_schema) { default_tenant } - - describe '#init' do - before do - Apartment.configure do |config| - config.excluded_models = ['Company'] - end - end - - after do - # 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 'should process model exclusions' do - Apartment::Tenant.init - - expect(Company.table_name).to eq('public.companies') - end - - context 'with a default_tenant', default_tenant: true do - it 'should set the proper table_name on excluded_models' do - Apartment::Tenant.init - - expect(Company.table_name).to eq("#{default_tenant}.companies") - end - - it 'sets the search_path correctly' do - Apartment::Tenant.init - - expect(User.connection.schema_search_path).to match(/|#{default_tenant}|/) - end - end - - context 'persistent_schemas', persistent_schemas: true 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(', ') - end - end - end - - # - # Creates happen already in our before_filter - # - describe '#create' do - it 'should load schema.rb to new schema' do - connection.schema_search_path = schema1 - expect(connection.tables).to include('companies') - end - - it 'should yield 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}") - User.create - end - - expect(connection.schema_search_path).not_to start_with %("#{schema2}") - - subject.switch(schema2) { expect(User.count).to eq(@count + 1) } - end - - context 'numeric database names' do - let(:db) { 1234 } - it 'should allow them' do - expect do - subject.create(db) - end.to_not raise_error - expect(tenant_names).to include(db.to_s) - end - - after { subject.drop(db) } - end - end - - describe '#drop' do - it 'should raise an error for unknown database' do - expect do - 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.to_not raise_error - expect(tenant_names).not_to include(db.to_s) - end - - after do - begin - subject.drop(db) - rescue StandardError => _e - nil - end - end - end - end - - describe '#switch' do - it 'connects and resets' do - subject.switch(schema1) do - expect(connection.schema_search_path).to start_with %("#{schema1}") - expect(User.sequence_name).to eq "#{schema1}.#{User.table_name}_id_seq" - end - - expect(connection.schema_search_path).to start_with %("#{public_schema}") - expect(User.sequence_name).to eq "#{public_schema}.#{User.table_name}_id_seq" - end - end - - describe '#reset' do - it 'should reset connection' do - subject.switch!(schema1) - subject.reset - 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 - subject.switch!(schema1) - subject.reset - expect(connection.schema_search_path).to start_with %("#{default_tenant}") - end - end - - context 'persistent_schemas', persistent_schemas: true 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(', ') - end - - context 'with default_tenant', default_tenant: true 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}") - end - end - end - end - - describe '#switch!' do - let(:tenant_presence_check) { true } - - before { Apartment.tenant_presence_check = tenant_presence_check } - - it 'should connect to new schema' do - subject.switch!(schema1) - expect(connection.schema_search_path).to start_with %("#{schema1}") - end - - it 'should reset connection if database is nil' do - subject.switch! - 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 - expect do - 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 - expect do - subject.switch! 'unknown_schema' - end.to_not raise_error(Apartment::TenantNotFound) - end - end - - context 'numeric databases' do - let(:db) { 1234 } - - it 'should connect to them' do - subject.create(db) - expect do - subject.switch!(db) - end.to_not raise_error - - expect(connection.schema_search_path).to start_with %("#{db}") - end - - after { subject.drop(db) } - end - - describe 'with default_tenant specified', default_tenant: true 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 - end - - it 'should still switch to the switched schema' do - expect(connection.schema_search_path).to start_with %("#{schema1}") - end - end - - context 'persistent_schemas', persistent_schemas: true 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(', ') - end - - it 'prioritizes the switched schema to front of schema_search_path' do - expect(connection.schema_search_path).to start_with %("#{schema1}") - end - end - end - - describe '#current' do - it 'should return the current schema name' do - subject.switch!(schema1) - expect(subject.current).to eq(schema1) - end - - context 'persistent_schemas', persistent_schemas: true do - it 'should exlude persistent_schemas' do - subject.switch!(schema1) - expect(subject.current).to eq(schema1) - end - end - end -end diff --git a/spec/integration/apartment_rake_integration_spec.rb b/spec/integration/apartment_rake_integration_spec.rb deleted file mode 100644 index fbfd0474..00000000 --- a/spec/integration/apartment_rake_integration_spec.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'rake' - -describe 'apartment rake tasks', database: :postgresql do - before do - @rake = Rake::Application.new - Rake.application = @rake - Dummy::Application.load_tasks - - # rails tasks running F up the schema... - Rake::Task.define_task('db:migrate') - Rake::Task.define_task('db:seed') - Rake::Task.define_task('db:rollback') - Rake::Task.define_task('db:migrate:up') - Rake::Task.define_task('db:migrate:down') - Rake::Task.define_task('db:migrate:redo') - - Apartment.configure do |config| - config.use_schemas = true - config.excluded_models = ['Company'] - config.tenant_names = -> { Company.pluck(:database) } - end - Apartment::Tenant.reload!(config) - - # fix up table name of shared/excluded models - Company.table_name = 'public.companies' - end - - after { Rake.application = nil } - - context 'with x number of databases' do - let(:x) { rand(1..5) } # random number of dbs to create - let(:db_names) { x.times.map { Apartment::Test.next_db } } - let!(:company_count) { db_names.length } - - before do - db_names.collect do |db_name| - Apartment::Tenant.create(db_name) - Company.create database: db_name - end - end - - after do - db_names.each { |db| Apartment::Tenant.drop(db) } - Company.delete_all - end - - context 'with ActiveRecord below 5.2.0' do - before do - allow(ActiveRecord::Migrator).to receive(:migrations_paths) { %w[spec/dummy/db/migrate] } - allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { true } - end - - describe '#migrate' do - it 'should migrate all databases' do - expect(ActiveRecord::Migrator).to receive(:migrate).exactly(company_count).times - - @rake['apartment:migrate'].invoke - end - end - - describe '#rollback' do - it 'should rollback all dbs' do - expect(ActiveRecord::Migrator).to receive(:rollback).exactly(company_count).times - - @rake['apartment:rollback'].invoke - end - end - end - - context 'with ActiveRecord above or equal to 5.2.0' do - let(:migration_context_double) { double(:migration_context) } - - before do - allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { false } - end - - describe '#migrate' do - it 'should migrate all databases' do - allow(ActiveRecord::Base.connection).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 - allow(ActiveRecord::Base.connection).to receive(:migration_context) { migration_context_double } - expect(migration_context_double).to receive(:rollback).exactly(company_count).times - - @rake['apartment:rollback'].invoke - end - end - end - - describe 'apartment:seed' do - it 'should seed all databases' do - expect(Apartment::Tenant).to receive(:seed).exactly(company_count).times - - @rake['apartment:seed'].invoke - end - end - end -end diff --git a/spec/integration/query_caching_spec.rb b/spec/integration/query_caching_spec.rb deleted file mode 100644 index 6026e5a1..00000000 --- a/spec/integration/query_caching_spec.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe 'query caching' do - describe 'when use_schemas = true' do - let(:db_names) { [db1, db2] } - - before do - Apartment.configure do |config| - config.excluded_models = ['Company'] - config.tenant_names = -> { Company.pluck(:database) } - config.use_schemas = true - end - - Apartment::Tenant.reload!(config) - - db_names.each do |db_name| - Apartment::Tenant.create(db_name) - Company.create database: db_name - end - end - - after do - db_names.each { |db| Apartment::Tenant.drop(db) } - Apartment::Tenant.reset - Company.delete_all - 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 - 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.last - expect(User.find_by(name: db_names.first)).to be_nil - end - end - - describe 'when use_schemas = false' do - let(:db_name) { db1 } - - before do - Apartment.configure do |config| - config.excluded_models = ['Company'] - config.tenant_names = -> { Company.pluck(:database) } - config.use_schemas = false - end - - Apartment::Tenant.reload!(config) - - Apartment::Tenant.create(db_name) - Company.create database: db_name - end - - after do - # Avoid cannot drop the currently open database. Maybe there is a better way to handle this. - Apartment::Tenant.switch! 'template1' - - Apartment::Tenant.drop(db_name) - Apartment::Tenant.reset - Company.delete_all - 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 - - ActiveRecord::Base.connection.disable_query_cache! - - 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 deleted file mode 100644 index 072efac6..00000000 --- a/spec/integration/use_within_an_engine_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -describe 'using apartment within an engine' do - before do - engine_path = Pathname.new(File.expand_path('../dummy_engine', __dir__)) - require engine_path.join('test/dummy/config/application') - @rake = Rake::Application.new - Rake.application = @rake - stub_const 'APP_RAKEFILE', engine_path.join('test/dummy/Rakefile') - load 'rails/tasks/engine.rake' - end - - it 'sucessfully runs rake db:migrate in the engine root' do - expect { Rake::Task['db:migrate'].invoke }.to_not raise_error - end - - it 'sucessfully runs rake app:db:migrate in the engine root' do - expect { Rake::Task['app:db:migrate'].invoke }.to_not raise_error - end - - context 'when Apartment.db_migrate_tenants is false' do - it 'should not enhance tasks' do - Apartment.db_migrate_tenants = false - expect(Apartment::RakeTaskEnhancer).to_not receive(:enhance_task).with('db:migrate') - Rake::Task['db:migrate'].invoke - end - end -end diff --git a/spec/integration/v4/cli_integration_spec.rb b/spec/integration/v4/cli_integration_spec.rb new file mode 100644 index 00000000..507664a5 --- /dev/null +++ b/spec/integration/v4/cli_integration_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative '../../../lib/apartment/cli' + +RSpec.describe('v4 CLI integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_cli') } + let(:tenants) { %w[cli_alpha cli_beta cli_gamma] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') if V4IntegrationHelper.sqlite? + FileUtils.rm_rf(tmp_dir) + end + + describe 'tenants list' do + it 'lists all tenants from tenants_provider' do + output = capture_stdout { Apartment::CLI::Tenants.new.invoke(:list) } + + tenants.each do |t| + expect(output).to(include(t), "Expected '#{t}' in list output") + end + end + end + + describe 'tenants create' do + it 'creates a tenant accessible via switch' do + capture_stdout { Apartment::CLI::Tenants.new.invoke(:create, ['cli_alpha']) } + + Apartment::Tenant.switch('cli_alpha') do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + ActiveRecord::Base.connection.execute('SELECT 1') + end + end + end + + describe 'tenants drop' do + it 'drops a tenant so it no longer exists' do + Apartment.adapter.create('cli_alpha') + + # Switch once to ensure the pool is tracked + Apartment::Tenant.switch('cli_alpha') do + ActiveRecord::Base.connection.execute('SELECT 1') + end + + role = ActiveRecord::Base.current_role + expect(Apartment.pool_manager.tracked?("cli_alpha:#{role}")).to(be(true)) + + begin + ENV['APARTMENT_FORCE'] = '1' + capture_stdout { Apartment::CLI::Tenants.new.invoke(:drop, ['cli_alpha']) } + ensure + ENV.delete('APARTMENT_FORCE') + end + + # After drop, the pool should be removed + expect(Apartment.pool_manager.tracked?("cli_alpha:#{role}")).to(be(false)) + end + end + + describe 'pool stats' do + it 'displays pool count and tenant names' do + Apartment.adapter.create('cli_alpha') + Apartment::Tenant.switch('cli_alpha') do + ActiveRecord::Base.connection.execute('SELECT 1') + end + + output = capture_stdout { Apartment::CLI::Pool.new.invoke(:stats) } + + expect(output).to(include('Total pools:')) + expect(output).to(include('cli_alpha')) + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/integration/v4/coverage_gaps_spec.rb b/spec/integration/v4/coverage_gaps_spec.rb new file mode 100644 index 00000000..7923dd4a --- /dev/null +++ b/spec/integration/v4/coverage_gaps_spec.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Coverage gaps integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_coverage_gaps') } + let(:tenants) { %w[tenant_a tenant_b] } + let(:extra_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + stub_const('Widget', Class.new(ActiveRecord::Base) do + self.table_name = 'widgets' + end) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |tenant| + Apartment.adapter.create(tenant) + Apartment::Tenant.switch(tenant) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + Apartment::Tenant.reset + + unless @tenants_cleaned + all_tenants = tenants + extra_tenants + V4IntegrationHelper.cleanup_tenants!(all_tenants, Apartment.adapter) + end + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + # ── 1. Tenant.pool_stats ──────────────────────────────────────────── + describe 'Tenant.pool_stats' do + it 'returns pool stats with real tenant pools' do + Apartment::Tenant.switch('tenant_a') { Widget.create!(name: 'a') } + Apartment::Tenant.switch('tenant_b') { Widget.create!(name: 'b') } + + role = ActiveRecord::Base.current_role + stats = Apartment::Tenant.pool_stats + expect(stats[:total_pools]).to(be >= 2) + expect(stats[:tenants]).to(include("tenant_a:#{role}", "tenant_b:#{role}")) + end + end + + # ── 2. PoolManager#stats_for ──────────────────────────────────────── + describe 'PoolManager#stats_for' do + it 'returns seconds_idle for a tracked tenant' do + Apartment::Tenant.switch('tenant_a') { Widget.create!(name: 'test') } + sleep(0.1) + + role = ActiveRecord::Base.current_role + stats = Apartment.pool_manager.stats_for("tenant_a:#{role}") + expect(stats).to(be_a(Hash)) + expect(stats[:seconds_idle]).to(be >= 0.1) + end + + it 'returns nil for an untracked tenant' do + expect(Apartment.pool_manager.stats_for('nonexistent')).to(be_nil) + end + end + + # ── 3. PoolManager#get touches timestamp ──────────────────────────── + describe 'PoolManager#get' do + it 'returns the pool and refreshes its timestamp' do + Apartment::Tenant.switch('tenant_a') { Widget.create!(name: 'a') } + sleep(0.1) + role = ActiveRecord::Base.current_role + idle_before = Apartment.pool_manager.stats_for("tenant_a:#{role}")[:seconds_idle] + + pool = Apartment.pool_manager.get("tenant_a:#{role}") + expect(pool).not_to(be_nil) + + idle_after = Apartment.pool_manager.stats_for("tenant_a:#{role}")[:seconds_idle] + expect(idle_after).to(be < idle_before) + end + + it 'returns nil for an untracked tenant without side effects' do + pool = Apartment.pool_manager.get('nonexistent') + expect(pool).to(be_nil) + expect(Apartment.pool_manager.tracked?('nonexistent')).to(be(false)) + end + end + + # ── 4. LRU eviction via PoolReaper ────────────────────────────────── + describe 'PoolReaper LRU eviction' do + let(:lru_tenants) { %w[lru_0 lru_1 lru_2 lru_3 lru_4 lru_5 lru_6] } + + before do + # Clean up default tenants first — we reconfigure below + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + @tenants_cleaned = true + Apartment.clear_config + Apartment::Current.reset + end + + after do + lru_tenants.each do |t| + Apartment.adapter&.drop(t) + rescue StandardError + nil + end + end + + it 'evicts LRU tenants when max_total_connections is exceeded' do + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + # Use high idle_timeout so only LRU eviction triggers, not idle eviction. + # max_total=3: evict_lru brings pool count down to 3. + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { lru_tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.pool_idle_timeout = 300 # high — idle eviction won't trigger + c.max_total_connections = 3 + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + # Create all tenants and access them to populate pool cache. + # Space out timestamps so LRU ordering is deterministic. + lru_tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + sleep(0.05) + end + + initial_count = Apartment.pool_manager.stats[:total_pools] + expect(initial_count).to(be > 3) + + # Touch the last 2 tenants so they are MRU + Apartment::Tenant.switch('lru_5') { Widget.create!(name: 'recent') } + sleep(0.02) + Apartment::Tenant.switch('lru_6') { Widget.create!(name: 'most_recent') } + + # Directly invoke run_cycle to avoid timing-dependent background thread. + Apartment.pool_reaper.run_cycle + + stats = Apartment.pool_manager.stats + expect(stats[:total_pools]).to(be <= 3) + + # The most recently accessed tenants should survive + role = ActiveRecord::Base.current_role + expect(Apartment.pool_manager.tracked?("lru_6:#{role}")).to(be(true)) + expect(Apartment.pool_manager.tracked?("lru_5:#{role}")).to(be(true)) + end + end + + # ── 5. Instrumentation ───────────────────────────────────────────── + describe 'Instrumentation' do + it 'emits create.apartment notification on tenant creation' do + events = [] + sub = ActiveSupport::Notifications.subscribe('create.apartment') do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + new_tenant = 'instrumented_create' + extra_tenants << new_tenant + Apartment.adapter.create(new_tenant) + + expect(events.size).to(eq(1)) + expect(events.first.payload[:tenant]).to(eq(new_tenant)) + ensure + ActiveSupport::Notifications.unsubscribe(sub) if sub + end + + it 'emits drop.apartment notification on tenant deletion' do + drop_tenant = 'instrumented_drop' + Apartment.adapter.create(drop_tenant) + + events = [] + sub = ActiveSupport::Notifications.subscribe('drop.apartment') do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + Apartment.adapter.drop(drop_tenant) + + expect(events.size).to(eq(1)) + expect(events.first.payload[:tenant]).to(eq(drop_tenant)) + ensure + ActiveSupport::Notifications.unsubscribe(sub) if sub + end + + it 'emits evict.apartment notification when reaper evicts a pool' do + events = [] + sub = ActiveSupport::Notifications.subscribe('evict.apartment') do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + # tenant_a was already accessed in before block via create+switch + # Wait for it to become idle and get reaped — but that requires reconfiguring + # with short timeouts. Instead, simulate eviction by directly calling the + # instrumentation path: remove the pool and fire the event manually. + # The real reaper integration is tested in the LRU and idle reaper tests. + Apartment::Instrumentation.instrument(:evict, tenant: 'tenant_a', reason: :idle) + + expect(events.size).to(eq(1)) + expect(events.first.payload[:tenant]).to(eq('tenant_a')) + expect(events.first.payload[:reason]).to(eq(:idle)) + ensure + ActiveSupport::Notifications.unsubscribe(sub) if sub + end + end + + # ── 6. Tenant.init processes excluded models ──────────────────────── + describe 'Tenant.init' do + it 'processes excluded models so they use the default connection' do + # Define a model class to act as excluded + stub_const('SharedRecord', Class.new(ActiveRecord::Base) do + self.table_name = 'widgets' + end) + + # Reconfigure with excluded_models + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.excluded_models = ['SharedRecord'] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + Apartment::Tenant.init + + # SharedRecord should have its own connection pool pinned to default + expect(SharedRecord.connection_pool).not_to(be_nil) + + # Switching tenants should not affect SharedRecord's connection + Apartment::Tenant.switch('tenant_a') do + # SharedRecord still resolves against the default tenant + expect(SharedRecord.connection).not_to(be_nil) + end + end + end + + # ── 7. PoolManager#lru_tenants ordering ───────────────────────────── + describe 'PoolManager#lru_tenants' do + it 'returns tenants ordered by least recently used' do + Apartment::Tenant.switch('tenant_a') { Widget.create!(name: 'a') } + sleep(0.05) + Apartment::Tenant.switch('tenant_b') { Widget.create!(name: 'b') } + + role = ActiveRecord::Base.current_role + lru = Apartment.pool_manager.lru_tenants(count: 2) + # tenant_a was accessed first, so it should appear before tenant_b + expect(lru.first).to(eq("tenant_a:#{role}")) + end + end +end diff --git a/spec/integration/v4/edge_cases_spec.rb b/spec/integration/v4/edge_cases_spec.rb new file mode 100644 index 00000000..dc004e82 --- /dev/null +++ b/spec/integration/v4/edge_cases_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Edge cases integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_edge_cases') } + let(:tenants) { %w[tenant_a tenant_b] } + let(:extra_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + stub_const('Widget', Class.new(ActiveRecord::Base) do + self.table_name = 'widgets' + end) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |tenant| + Apartment.adapter.create(tenant) + Apartment::Tenant.switch(tenant) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + # Reset tenant context before cleanup to avoid being stuck in a tenant + Apartment::Tenant.reset + + unless @tenants_cleaned + all_tenants = tenants + extra_tenants + V4IntegrationHelper.cleanup_tenants!(all_tenants, Apartment.adapter) + end + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + describe 'switch! (non-block form)' do + it 'sets tenant and resolves correct pool' do + Apartment::Tenant.switch!('tenant_a') + Widget.create!(name: 'via_switch_bang') + expect(Widget.count).to(eq(1)) + + Apartment::Tenant.switch!('tenant_b') + expect(Widget.count).to(eq(0)) + + Apartment::Tenant.reset + end + end + + describe 'switching to default tenant explicitly' do + it 'uses the default pool' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'in_a') + end + + default = V4IntegrationHelper.default_tenant + Apartment::Tenant.switch(default) do + expect(Apartment::Tenant.current).to(eq(default)) + end + end + end + + describe 'nested create inside switch' do + it 'creating a tenant inside a switch block preserves outer tenant context' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'before_create') + + Apartment.adapter.create('nested_tenant') + extra_tenants << 'nested_tenant' + + # After create returns, we should still be in tenant_a + expect(Apartment::Tenant.current).to(eq('tenant_a')) + expect(Widget.count).to(eq(1)) + end + end + end + + describe 'clear_config' do + it 'disconnects all pools and stops the reaper' do + Apartment::Tenant.switch('tenant_a') { Widget.create!(name: 'a') } + Apartment::Tenant.switch('tenant_b') { Widget.create!(name: 'b') } + + expect(Apartment.pool_manager.stats[:total_pools]).to(be >= 2) + reaper = Apartment.pool_reaper + + # Clean up tenants BEFORE clear_config destroys the adapter + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + @tenants_cleaned = true + + Apartment.clear_config + + expect(Apartment.pool_manager).to(be_nil) + expect(Apartment.pool_reaper).to(be_nil) + expect(reaper).not_to(be_running) + end + end + + describe 'seed' do + it 'loads the seed file inside the tenant context' do + seed_file = File.join(tmp_dir, 'seeds.rb') + File.write(seed_file, "Widget.create!(name: 'seeded')") + + # Use a dedicated tenant for seed tests to avoid conflicts with main before block. + seed_tenant = 'seed_test' + extra_tenants << seed_tenant + + # Reconfigure with the seed file + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { [seed_tenant] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.seed_data_file = seed_file + c.check_pending_migrations = false + end + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + Apartment.adapter.create(seed_tenant) + Apartment::Tenant.switch(seed_tenant) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + + Apartment.adapter.seed(seed_tenant) + + Apartment::Tenant.switch(seed_tenant) do + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('seeded')) + end + end + + it 'raises ConfigurationError when seed file does not exist' do + seed_tenant = 'seed_missing_test' + extra_tenants << seed_tenant + + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { [seed_tenant] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.seed_data_file = '/tmp/definitely_does_not_exist_xyz.rb' + c.check_pending_migrations = false + end + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + Apartment.adapter.create(seed_tenant) + + expect { Apartment.adapter.seed(seed_tenant) }.to(raise_error( + Apartment::ConfigurationError, /does not exist/ + )) + end + end + + describe 'migrate with real schema change' do + it 'adds a column visible only in the target tenant' do + Apartment::Tenant.switch('tenant_a') do + ActiveRecord::Base.connection.add_column(:widgets, :color, :string) + end + + Apartment::Tenant.switch('tenant_a') do + Widget.reset_column_information + expect(Widget.column_names).to(include('color')) + end + + Apartment::Tenant.switch('tenant_b') do + Widget.reset_column_information + expect(Widget.column_names).not_to(include('color')) + end + + # Reset column info so other tests aren't affected + Widget.reset_column_information + end + end + + describe 'empty string tenant name' do + it 'treats empty string as a distinct tenant value' do + # The ConnectionHandling patch treats '' as a tenant (not nil), + # which means it will attempt pool resolution for ''. + # Current.tenant will be '' inside the block. + Apartment::Tenant.switch('') do + expect(Apartment::Tenant.current).to(eq('')) + end + end + end +end diff --git a/spec/integration/v4/excluded_models_spec.rb b/spec/integration/v4/excluded_models_spec.rb new file mode 100644 index 00000000..0649a637 --- /dev/null +++ b/spec/integration/v4/excluded_models_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require 'apartment/concerns/model' + +RSpec.describe('v4 Pinned models integration (Apartment::Model)', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_pinned') } + let(:created_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + # Create tables in default database + ActiveRecord::Base.connection.create_table(:global_settings, force: true) do |t| + t.string(:key) + t.string(:value) + end + V4IntegrationHelper.create_test_table! + + # Simulate ApplicationRecord for realistic topology + stub_const('ApplicationRecord', Class.new(ActiveRecord::Base) do + self.abstract_class = true + end) + + stub_const('GlobalSetting', Class.new(ApplicationRecord) do + self.table_name = 'global_settings' + include Apartment::Model + + pin_tenant + end) + + stub_const('Widget', Class.new(ApplicationRecord) do + self.table_name = 'widgets' + end) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { %w[tenant_a] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + Apartment.adapter.process_pinned_models + + Apartment.adapter.create('tenant_a') + created_tenants << 'tenant_a' + Apartment::Tenant.switch('tenant_a') do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(created_tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'pin_tenant establishes a dedicated connection for the model' do + expect(GlobalSetting.connection_specification_name).not_to(eq(ActiveRecord::Base.connection_specification_name)) + end + + it 'pin_tenant is idempotent' do + expect { Apartment.adapter.process_pinned_models }.not_to(raise_error) + expect(GlobalSetting.connection_specification_name).not_to(eq(ActiveRecord::Base.connection_specification_name)) + end + + it 'pinned model queries always target the default database' do + GlobalSetting.create!(key: 'site_name', value: 'TestSite') + + Apartment::Tenant.switch('tenant_a') do + expect(GlobalSetting.count).to(eq(1)) + expect(GlobalSetting.first.key).to(eq('site_name')) + expect(Widget.count).to(eq(0)) + end + end + + it 'pinned model data persists across tenant switches' do + GlobalSetting.create!(key: 'version', value: '1.0') + + Apartment::Tenant.switch('tenant_a') do + expect(GlobalSetting.find_by(key: 'version').value).to(eq('1.0')) + end + + expect(GlobalSetting.count).to(eq(1)) + end + + it 'pinned model writes inside a tenant block land in the default database' do + Apartment::Tenant.switch('tenant_a') do + GlobalSetting.create!(key: 'inside_tenant', value: 'yes') + end + + expect(GlobalSetting.find_by(key: 'inside_tenant')).to(be_present) + end + + it 'tenant model (Widget) still routes through tenant pool during switch' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'in_tenant') + expect(Widget.count).to(eq(1)) + end + + # Back in default — tenant widget not visible (different database/schema) + # For PG schema, public.widgets might exist; for DB-per-tenant, no widgets table in default + if V4IntegrationHelper.postgresql? + # Schema strategy: widgets table exists in public, should be empty + expect(Widget.count).to(eq(0)) + end + end + + context 'ApplicationRecord topology' do + it 'normal models inheriting from ApplicationRecord get tenant routing' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'routed_correctly') + expect(Widget.count).to(eq(1)) + end + end + end + + context 'STI subclass of pinned model' do + before do + stub_const('AdminSetting', Class.new(GlobalSetting)) + end + + it 'inherits pinned behavior' do + AdminSetting.create!(key: 'admin_only', value: 'true') + + Apartment::Tenant.switch('tenant_a') do + expect(AdminSetting.find_by(key: 'admin_only').value).to(eq('true')) + end + end + end + + context 'config.excluded_models shim' do + it 'still works via deprecated path' do + # Re-setup with config.excluded_models instead of pin_tenant + stub_const('LegacySetting', Class.new(ApplicationRecord) do + self.table_name = 'global_settings' + end) + + Apartment.clear_config + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { %w[tenant_a] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.excluded_models = ['LegacySetting'] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + Apartment::Tenant.init + + expect(Apartment.pinned_models).to(include(LegacySetting)) + + LegacySetting.create!(key: 'legacy', value: 'works') + Apartment::Tenant.switch('tenant_a') do + expect(LegacySetting.find_by(key: 'legacy').value).to(eq('works')) + end + end + end + + sqlite_skip = V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.sqlite? && + 'SQLite single-writer lock causes BusyException' + + context 'concurrent pinned model access', :stress, skip: sqlite_skip do + it 'two threads in different tenants both read/write the pinned model to default' do + GlobalSetting.create!(key: 'shared', value: 'initial') + + threads = Array.new(2) do |i| + Thread.new do # rubocop:disable ThreadSafety/NewThread + Apartment::Tenant.switch('tenant_a') do + GlobalSetting.create!(key: "thread_#{i}", value: "val_#{i}") + sleep(0.01) # brief yield to increase interleaving + GlobalSetting.find_by(key: "thread_#{i}") + end + end + end + + threads.each(&:join) + expect(GlobalSetting.count).to(eq(3)) # initial + 2 threads + end + end +end diff --git a/spec/integration/v4/fiber_safety_spec.rb b/spec/integration/v4/fiber_safety_spec.rb new file mode 100644 index 00000000..60cebcd7 --- /dev/null +++ b/spec/integration/v4/fiber_safety_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Fiber safety integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_fiber') } + let(:tenants) { %w[fiber_a fiber_b] } + + before do + # v4 requires fiber isolation for CurrentAttributes to be fiber-local. + @original_isolation_level = ActiveSupport::IsolatedExecutionState.isolation_level + ActiveSupport::IsolatedExecutionState.isolation_level = :fiber + + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + ActiveSupport::IsolatedExecutionState.isolation_level = @original_isolation_level + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') if V4IntegrationHelper.sqlite? + FileUtils.rm_rf(tmp_dir) + end + + it 'isolates tenant state across fibers' do + Apartment::Tenant.switch('fiber_a') do + child_tenant = Fiber.new do + Apartment::Tenant.switch('fiber_b') do + Fiber.yield(Apartment::Tenant.current) + end + end + + child_result = child_tenant.resume + expect(child_result).to(eq('fiber_b')) + expect(Apartment::Tenant.current).to(eq('fiber_a')) + expect(Apartment::Current.tenant).to(eq('fiber_a')) + end + end + + it 'preserves tenant across Fiber.yield/resume cycles' do + fiber = Fiber.new do + Apartment::Tenant.switch('fiber_a') do + Fiber.yield(:switched) + Apartment::Tenant.current + end + end + + expect(fiber.resume).to(eq(:switched)) + expect(fiber.resume).to(eq('fiber_a')) + end + + it 'outer switch block unaffected by inner fiber switching' do + Apartment::Tenant.switch('fiber_a') do + Widget.create!(name: 'outer') + + fiber = Fiber.new do + Apartment::Tenant.switch('fiber_b') do + Widget.create!(name: 'inner') + Apartment::Tenant.current + end + end + + inner_tenant = fiber.resume + expect(inner_tenant).to(eq('fiber_b')) + expect(Apartment::Tenant.current).to(eq('fiber_a')) + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('outer')) + end + + Apartment::Tenant.switch('fiber_b') do + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('inner')) + end + end + + # Exercises scheduled fibers under a real Fiber::Scheduler implementation + # (e.g., async gem). Standard MRI does not define Fiber::Scheduler as a + # concrete class, so this skips when the constant is absent. + context 'Fiber.scheduler integration' do + it 'tenant state does not leak across scheduled fibers' do + results = [] + mutex = Mutex.new + + scheduler = Fiber::Scheduler.new if defined?(Fiber::Scheduler) + skip 'no built-in Fiber::Scheduler available' unless scheduler + + Fiber.set_scheduler(scheduler) + + Fiber.schedule do + Apartment::Tenant.switch('fiber_a') do + sleep(0.01) # yield to scheduler + mutex.synchronize { results << { fiber: :a, tenant: Apartment::Tenant.current } } + end + end + + Fiber.schedule do + Apartment::Tenant.switch('fiber_b') do + sleep(0.01) # yield to scheduler + mutex.synchronize { results << { fiber: :b, tenant: Apartment::Tenant.current } } + end + end + + Fiber.scheduler.close + Fiber.set_scheduler(nil) + + a_result = results.find { |r| r[:fiber] == :a } + b_result = results.find { |r| r[:fiber] == :b } + + expect(a_result).not_to(be_nil, 'Fiber A did not produce a result') + expect(b_result).not_to(be_nil, 'Fiber B did not produce a result') + expect(a_result[:tenant]).to(eq('fiber_a')) + expect(b_result[:tenant]).to(eq('fiber_b')) + end + end + + # Smoke test: load_async under tenant context. On MRI/SQLite this typically + # executes synchronously; the value is proving the tenant pool resolves correctly + # when the load_async code path is exercised, not testing true async dispatch. + context 'load_async integration', + skip: (ActiveRecord::Relation.method_defined?(:load_async) ? false : 'requires load_async support') do + it 'async relation resolves against the correct tenant pool' do + Apartment::Tenant.switch('fiber_a') do + Widget.create!(name: 'async_test') + end + + Apartment::Tenant.switch('fiber_a') do + relation = Widget.where(name: 'async_test').load_async + # Force resolution + results = relation.to_a + expect(results.size).to(eq(1)) + expect(results.first.name).to(eq('async_test')) + end + + # Verify it didn't leak into fiber_b + Apartment::Tenant.switch('fiber_b') do + expect(Widget.where(name: 'async_test').count).to(eq(0)) + end + end + end +end diff --git a/spec/integration/v4/memory_stability_spec.rb b/spec/integration/v4/memory_stability_spec.rb new file mode 100644 index 00000000..4acf74d7 --- /dev/null +++ b/spec/integration/v4/memory_stability_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Memory stability integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + # ── Pool count stays bounded under max_total_connections ─────────── + context 'bounded pool count', + skip: (if V4IntegrationHelper.sqlite? + 'SQLite pool-per-tenant less meaningful with single-writer lock' + else + false + end) do + let(:tmp_dir) { Dir.mktmpdir('apartment_mem_bounded') } + let(:tenants) { Array.new(20) { |i| "mem_bounded_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.pool_idle_timeout = 300 + c.max_total_connections = 5 + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + FileUtils.rm_rf(tmp_dir) + end + + it 'pool count stays within max_total_connections after reaper cycles' do + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + 3.times do |cycle| + tenants.each do |t| + Apartment::Tenant.switch(t) do + Widget.create!(name: "cycle_#{cycle}") + end + end + + if cycle.zero? + pre_reap = Apartment.pool_manager.stats[:total_pools] + expect(pre_reap).to(be > 5, + "Cycle 0: expected > 5 pools before reap, got #{pre_reap}") + end + + Apartment.pool_reaper.run_cycle + + pool_count = Apartment.pool_manager.stats[:total_pools] + expect(pool_count).to(be <= 5, + "Cycle #{cycle}: expected <= 5 pools after reap, got #{pool_count}") + end + end + end + + # ── Repeated create/drop doesn't leak pools ──────────────────────── + context 'create/drop cycle', + skip: (if V4IntegrationHelper.sqlite? + 'SQLite pool-per-tenant less meaningful with single-writer lock' + else + false + end) do + let(:tmp_dir) { Dir.mktmpdir('apartment_mem_cycle') } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { [] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + end + + after do + Apartment.clear_config + Apartment::Current.reset + FileUtils.rm_rf(tmp_dir) + end + + it 'pool count returns to baseline after 20 create/drop cycles' do + baseline = Apartment.pool_manager.stats[:total_pools] + + 20.times do |i| + tenant = "ephemeral_#{i}" + Apartment.adapter.create(tenant) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute('SELECT 1') + end + Apartment.adapter.drop(tenant) + end + + final = Apartment.pool_manager.stats[:total_pools] + expect(final).to(be <= baseline + 1, + "Expected pool count near baseline #{baseline}, got #{final}") + end + end + + # ── Sustained switching without pool growth ───────────────────────── + context 'sustained switching' do + let(:tmp_dir) { Dir.mktmpdir('apartment_mem_sustained') } + let(:tenants) { Array.new(5) { |i| "sustained_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.max_total_connections = 100 + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') if V4IntegrationHelper.sqlite? + FileUtils.rm_rf(tmp_dir) + end + + it 'no phantom pools after 200 round-robin switches' do + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + # Prime all tenant pools + tenants.each do |t| + Apartment::Tenant.switch(t) { Widget.create!(name: 'prime') } + end + + expected_pools = Apartment.pool_manager.stats[:total_pools] + + 200.times do |i| + tenant = tenants[i % tenants.size] + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute('SELECT 1') + end + end + + final_pools = Apartment.pool_manager.stats[:total_pools] + expect(final_pools).to(eq(expected_pools), + "Expected #{expected_pools} pools after 200 switches, got #{final_pools}") + end + end +end diff --git a/spec/integration/v4/migrator_integration_spec.rb b/spec/integration/v4/migrator_integration_spec.rb new file mode 100644 index 00000000..81485f33 --- /dev/null +++ b/spec/integration/v4/migrator_integration_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require 'apartment/migrator' + +# Migrator integration tests require a real database and Rails environment. +# Run via appraisal: +# bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/migrator_integration_spec.rb +# +# PostgreSQL and MySQL also work; SQLite is simplest (no external DB). +RSpec.describe('v4 Migrator integration', :integration) do + before(:all) do + skip('requires ActiveRecord + database gem') unless V4_INTEGRATION_AVAILABLE + end + + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_migrator') } + let(:migrations_dir) { File.join(tmp_dir, 'migrate') } + let(:test_tenants) { %w[migrate_test_a migrate_test_b migrate_test_c] } + let(:original_migrations_paths) { ActiveRecord::Migrator.migrations_paths.dup } + + # Writes a trivial reversible migration into the given directory. + def write_test_migration(dir) + FileUtils.mkdir_p(dir) + File.write(File.join(dir, '20240101000001_create_migrator_test_widgets.rb'), <<~RUBY) + # frozen_string_literal: true + class CreateMigratorTestWidgets < ActiveRecord::Migration[7.0] + def change + create_table(:migrator_test_widgets, force: true) do |t| + t.string :name + end + end + end + RUBY + end + + before do + write_test_migration(migrations_dir) + + # Point all AR migration contexts at our temp directory for this test. + ActiveRecord::Migrator.migrations_paths = [migrations_dir] + + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { test_tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + test_tenants.each { |t| Apartment.adapter.create(t) } + end + + after do + ActiveRecord::Migrator.migrations_paths = original_migrations_paths + Apartment::Tenant.reset + V4IntegrationHelper.cleanup_tenants!(test_tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + describe 'sequential migration (threads: 0)' do + it 'returns a MigrationRun covering all tenants' do + migrator = Apartment::Migrator.new(threads: 0) + run = migrator.run + + expect(run).to(be_a(Apartment::Migrator::MigrationRun)) + expect(run.threads).to(eq(0)) + expect(run).to(be_success) + + tenant_results = run.results.reject { |r| r.tenant == Apartment.config.default_tenant } + expect(tenant_results.size).to(eq(test_tenants.size)) + end + + it 'marks each tenant :success or :skipped with no error' do + run = Apartment::Migrator.new(threads: 0).run + + run.results.each do |result| + expect(result).to(be_a(Apartment::Migrator::Result)) + expect(result.status).to(be_in(%i[success skipped])) + expect(result.error).to(be_nil) + expect(result.duration).to(be >= 0) + end + end + + it 'includes a result for the primary (default) tenant' do + run = Apartment::Migrator.new(threads: 0).run + + primary = run.results.find { |r| r.tenant == Apartment.config.default_tenant } + expect(primary).not_to(be_nil) + expect(primary.status).to(be_in(%i[success skipped])) + end + + it 'returns a non-empty summary string' do + run = Apartment::Migrator.new(threads: 0).run + + expect(run.summary).to(be_a(String)) + expect(run.summary).not_to(be_empty) + expect(run.summary).to(include('tenant')) + end + end + + describe 'parallel migration (threads: 2)', + skip: (V4IntegrationHelper.sqlite? ? 'SQLite does not support concurrent connections' : false) do + it 'returns a MigrationRun covering all tenants' do + migrator = Apartment::Migrator.new(threads: 2) + run = migrator.run + + expect(run).to(be_a(Apartment::Migrator::MigrationRun)) + expect(run.threads).to(eq(2)) + expect(run).to(be_success) + + tenant_results = run.results.reject { |r| r.tenant == Apartment.config.default_tenant } + expect(tenant_results.size).to(eq(test_tenants.size)) + end + + it 'produces no failures and records a positive total_duration' do + run = Apartment::Migrator.new(threads: 2).run + + expect(run.failed).to(be_empty) + expect(run.total_duration).to(be > 0) + end + + it 'marks each tenant :success or :skipped with no error' do + run = Apartment::Migrator.new(threads: 2).run + + run.results.each do |result| + expect(result.status).to(be_in(%i[success skipped])) + expect(result.error).to(be_nil) + end + end + end + + describe 'idempotency' do + it 'returns all :skipped results on a second run' do + first_run = Apartment::Migrator.new(threads: 0).run + expect(first_run).to(be_success) + + second_run = Apartment::Migrator.new(threads: 0).run + expect(second_run).to(be_success) + + second_run.results.each do |result| + expect(result.status).to(eq(:skipped), + "Expected '#{result.tenant}' to be :skipped on second run, got :#{result.status}") + end + end + + it 'idempotency holds under parallel execution as well', + skip: (V4IntegrationHelper.sqlite? ? 'SQLite does not support concurrent connections' : false) do + Apartment::Migrator.new(threads: 2).run + + second_run = Apartment::Migrator.new(threads: 2).run + expect(second_run).to(be_success) + second_run.results.each do |result| + expect(result.status).to(eq(:skipped)) + end + end + end +end diff --git a/spec/integration/v4/migrator_rbac_spec.rb b/spec/integration/v4/migrator_rbac_spec.rb new file mode 100644 index 00000000..5cfa3495 --- /dev/null +++ b/spec/integration/v4/migrator_rbac_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' +require 'apartment/migrator' + +RSpec.describe('Migrator with migration_role', :integration, :postgresql_only, :rbac, + skip: (V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.postgresql? ? false : 'requires PG')) do + include V4IntegrationHelper + + let(:tenants) { %w[rbac_mig_one rbac_mig_two] } + let(:migration_dir) { Dir.mktmpdir('apartment_rbac_migrations') } + + before do + config = V4IntegrationHelper.establish_default_connection! + RbacHelper.setup_connects_to!(config) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { tenants } + c.default_tenant = 'public' + c.migration_role = :db_manager + c.app_role = RbacHelper::ROLES[:app_user] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + # Create tenants as db_manager (so db_manager owns the schemas) + ActiveRecord::Base.connected_to(role: :db_manager) do + tenants.each { |t| Apartment.adapter.create(t) } + end + + # Write a real migration file. [7.2] is the minimum supported Rails version + # in the CI matrix; stable across 7.2/8.0/8.1. + timestamp = '20260401000001' + File.write(File.join(migration_dir, "#{timestamp}_create_rbac_test_widgets.rb"), <<~RUBY) + class CreateRbacTestWidgets < ActiveRecord::Migration[7.2] + def change + create_table :rbac_test_widgets do |t| + t.string :name + end + end + end + RUBY + + # Point AR's migration context at our temp directory. + # ActiveRecord::Migrator.migrations_paths is what connection_pool.migration_context reads. + @original_migrations_paths = ActiveRecord::Migrator.migrations_paths + ActiveRecord::Migrator.migrations_paths = [migration_dir] + end + + after do + RbacHelper.teardown_rbac_connections! + ActiveRecord::Migrator.migrations_paths = @original_migrations_paths + + V4IntegrationHelper.establish_default_connection! + Apartment.adapter = V4IntegrationHelper.build_adapter( + V4IntegrationHelper.default_connection_config + ) + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + FileUtils.rm_rf(migration_dir) + end + + it 'runs migrations as db_manager (table owned by db_manager)' do + migrator = Apartment::Migrator.new(threads: 0) + result = migrator.run + + expect(result).to(be_success) + + tenants.each do |t| + Apartment::Tenant.switch(t) do + conn = ActiveRecord::Base.connection + owner = conn.execute(<<~SQL.squish).first['tableowner'] + SELECT tableowner FROM pg_tables + WHERE schemaname = #{conn.quote(t)} AND tablename = 'rbac_test_widgets' + SQL + expect(owner).to(eq(RbacHelper::ROLES[:db_manager])) + end + end + end + + it 'app_user can DML on migrated tables via default privileges' do + Apartment::Migrator.new(threads: 0).run + + RbacHelper.connect_as(:app_user) + conn = ActiveRecord::Base.connection + + tenants.each do |t| + conn.execute("INSERT INTO #{conn.quote_table_name(t)}.rbac_test_widgets (name) VALUES ('test')") + result = conn.execute("SELECT name FROM #{conn.quote_table_name(t)}.rbac_test_widgets") + expect(result.first['name']).to(eq('test')) + end + ensure + RbacHelper.restore_default_connection! + end + + it 'evicts migration-role pools after run' do + Apartment::Migrator.new(threads: 0).run + + # Intentionally coupled to pool key format ("tenant:role") — regression guard for evict_migration_pools + db_mgr_keys = Apartment.pool_manager.stats[:tenants].select { |k| k.end_with?(':db_manager') } + expect(db_mgr_keys).to(be_empty) + end + + # :stress skips the per-example ConnectionHandler swap. Worker threads need + # the :db_manager pool registered by setup_connects_to! to remain visible; + # the handler swap discards it before threads can resolve it via super. + context 'with parallel threads', :stress do + it 'each thread uses db_manager credentials' do + migrator = Apartment::Migrator.new(threads: 2) + result = migrator.run + + expect(result).to(be_success) + + tenants.each do |t| + Apartment::Tenant.switch(t) do + conn = ActiveRecord::Base.connection + owner = conn.execute(<<~SQL.squish).first['tableowner'] + SELECT tableowner FROM pg_tables + WHERE schemaname = #{conn.quote(t)} AND tablename = 'rbac_test_widgets' + SQL + expect(owner).to(eq(RbacHelper::ROLES[:db_manager])) + end + end + end + end +end diff --git a/spec/integration/v4/mysql_rbac_grants_spec.rb b/spec/integration/v4/mysql_rbac_grants_spec.rb new file mode 100644 index 00000000..169b1208 --- /dev/null +++ b/spec/integration/v4/mysql_rbac_grants_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' + +RSpec.describe('MySQL RBAC privilege grants', :integration, :mysql_only, :rbac, + skip: (V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.mysql? ? false : 'requires MySQL')) do + include V4IntegrationHelper + + let(:tenant) { 'rbac_grants_tenant' } + + before do + config = V4IntegrationHelper.establish_default_connection! + + # Create tenant as db_manager with app_role grants + RbacHelper.connect_as(:db_manager) + + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [tenant] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.app_role = RbacHelper::ROLES[:app_user] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter( + config.merge('username' => RbacHelper::ROLES[:db_manager]) + ) + Apartment.activate! + Apartment.adapter.create(tenant) + + # Create a test table inside the tenant database + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute(<<~SQL.squish) + CREATE TABLE widgets ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) + ) + SQL + end + + RbacHelper.restore_default_connection! + end + + after do + RbacHelper.teardown_rbac_connections! + V4IntegrationHelper.establish_default_connection! + Apartment.adapter = V4IntegrationHelper.build_adapter( + V4IntegrationHelper.default_connection_config + ) + V4IntegrationHelper.cleanup_tenants!([tenant], Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + context 'as app_user' do + let(:db_name) { Apartment.adapter.environmentify(tenant) } + + # Connect without a default database — app_user only has per-tenant grants + # from Mysql2Adapter#grant_privileges, not access to the default test DB. + # All queries use schema-qualified `db_name`.table syntax. + before { RbacHelper.connect_as(:app_user, 'database' => nil) } + after { RbacHelper.restore_default_connection! } + + it 'can SELECT, INSERT, UPDATE, DELETE' do + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO `#{db_name}`.widgets (name) VALUES ('test')") + + # select_value returns a scalar; avoids Mysql2::Result hash-vs-array ambiguity + val = conn.select_value("SELECT name FROM `#{db_name}`.widgets") + expect(val).to(eq('test')) + + conn.execute("UPDATE `#{db_name}`.widgets SET name = 'updated'") + conn.execute("DELETE FROM `#{db_name}`.widgets") + end + + it 'cannot CREATE TABLE in the tenant database' do + expect do + ActiveRecord::Base.connection.execute( + "CREATE TABLE `#{db_name}`.forbidden (id INT PRIMARY KEY)" + ) + end.to(raise_error(ActiveRecord::StatementInvalid, /command denied|Access denied/)) + end + + it 'cannot DROP DATABASE' do + expect do + ActiveRecord::Base.connection.execute("DROP DATABASE `#{db_name}`") + end.to(raise_error(ActiveRecord::StatementInvalid, /command denied|Access denied/)) + end + end + + context 'as db_manager' do + let(:db_name) { Apartment.adapter.environmentify(tenant) } + + before { RbacHelper.connect_as(:db_manager) } + after { RbacHelper.restore_default_connection! } + + it 'can CREATE TABLE and DROP it' do + conn = ActiveRecord::Base.connection + conn.execute("CREATE TABLE `#{db_name}`.temp_table (id INT PRIMARY KEY)") + conn.execute("DROP TABLE `#{db_name}`.temp_table") + end + end +end diff --git a/spec/integration/v4/mysql_spec.rb b/spec/integration/v4/mysql_spec.rb new file mode 100644 index 00000000..4d4e742e --- /dev/null +++ b/spec/integration/v4/mysql_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +unless defined?(Rails) + module Rails + def self.env + 'test' + end + end +end + +RSpec.describe('v4 MySQL integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + before(:all) do + skip('MySQL-only tests') unless V4IntegrationHelper.mysql? + end + + let(:created_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! + @config = V4IntegrationHelper.establish_default_connection! + + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [] } + c.default_tenant = 'default' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(@config) + Apartment.activate! + end + + after do + V4IntegrationHelper.cleanup_tenants!(created_tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + describe 'database creation' do + it 'creates a tenant database visible in SHOW DATABASES' do + Apartment.adapter.create('mysql_create_test') + created_tenants << 'mysql_create_test' + + databases = ActiveRecord::Base.connection.select_values('SHOW DATABASES') + expect(databases).to(include('mysql_create_test')) + end + end + + describe 'database drop' do + it 'removes the tenant database from SHOW DATABASES' do + Apartment.adapter.create('mysql_drop_test') + + databases_before = ActiveRecord::Base.connection.select_values('SHOW DATABASES') + expect(databases_before).to(include('mysql_drop_test')) + + Apartment.adapter.drop('mysql_drop_test') + + databases_after = ActiveRecord::Base.connection.select_values('SHOW DATABASES') + expect(databases_after).not_to(include('mysql_drop_test')) + end + end + + describe 'data isolation across databases' do + before do + %w[iso_a iso_b].each do |tenant| + Apartment.adapter.create(tenant) + created_tenants << tenant + Apartment::Tenant.switch(tenant) do + V4IntegrationHelper.create_test_table!('widgets') + end + end + + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + end + + it 'isolates records between tenant databases' do + Apartment::Tenant.switch('iso_a') do + Widget.create!(name: 'Alpha') + Widget.create!(name: 'Beta') + end + + Apartment::Tenant.switch('iso_b') do + expect(Widget.count).to(eq(0)) + end + + Apartment::Tenant.switch('iso_a') do + expect(Widget.count).to(eq(2)) + expect(Widget.pluck(:name)).to(contain_exactly('Alpha', 'Beta')) + end + end + end + + describe 'tables are independent per database' do + before do + %w[tbl_a tbl_b].each do |tenant| + Apartment.adapter.create(tenant) + created_tenants << tenant + Apartment::Tenant.switch(tenant) do + V4IntegrationHelper.create_test_table!('widgets') + end + end + + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + end + + it 'returns correct data per database after switching' do + Apartment::Tenant.switch('tbl_a') do + Widget.create!(name: 'Widget A1') + Widget.create!(name: 'Widget A2') + end + + Apartment::Tenant.switch('tbl_b') do + Widget.create!(name: 'Widget B1') + end + + Apartment::Tenant.switch('tbl_a') do + expect(Widget.count).to(eq(2)) + expect(Widget.pluck(:name)).to(contain_exactly('Widget A1', 'Widget A2')) + end + + Apartment::Tenant.switch('tbl_b') do + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('Widget B1')) + end + end + end + + describe 'environmentified database names' do + after do + # Drop the environmentified database directly in case cleanup_tenants! misses it + ActiveRecord::Base.connection.execute('DROP DATABASE IF EXISTS `test_acme`') + end + + it 'creates a database prefixed with Rails environment' do + Apartment.clear_config + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [] } + c.default_tenant = 'default' + c.environmentify_strategy = :prepend + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(@config) + Apartment.activate! + + Apartment.adapter.create('acme') + created_tenants << 'acme' + + databases = ActiveRecord::Base.connection.select_values('SHOW DATABASES') + expect(databases).to(include('test_acme')) + end + end + + describe 'resolve_connection_config' do + it 'returns config with the correct database value' do + resolved = Apartment.adapter.resolve_connection_config('acme') + expect(resolved).to(be_a(Hash)) + expect(resolved['database']).to(eq('acme')) + end + + context 'with environmentify_strategy :prepend' do + before do + Apartment.clear_config + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [] } + c.default_tenant = 'default' + c.environmentify_strategy = :prepend + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(@config) + end + + it 'returns config with environmentified database value' do + resolved = Apartment.adapter.resolve_connection_config('acme') + expect(resolved['database']).to(eq('test_acme')) + end + end + end +end diff --git a/spec/integration/v4/postgresql_database_rbac_spec.rb b/spec/integration/v4/postgresql_database_rbac_spec.rb new file mode 100644 index 00000000..87225fe7 --- /dev/null +++ b/spec/integration/v4/postgresql_database_rbac_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' + +unless defined?(Rails) + module Rails + def self.env + 'test' + end + end +end + +RSpec.describe('PostgreSQL database-per-tenant callable app_role', :integration, :postgresql_only, :rbac, + skip: (V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.postgresql? ? false : 'requires PG')) do + include V4IntegrationHelper + + let(:tenant) { 'apt_db_rbac_tenant' } + + # Force-drop a PG database by terminating active connections first. + def force_drop_database(db_name) + conn = ActiveRecord::Base.connection + conn.execute(<<~SQL.squish) + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = #{conn.quote(db_name)} AND pid <> pg_backend_pid() + SQL + conn.execute("DROP DATABASE IF EXISTS #{conn.quote_table_name(db_name)}") + rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished => e + warn "force_drop_database(#{db_name}): #{e.class}: #{e.message}" + end + + before do + V4IntegrationHelper.ensure_test_database! + @config = V4IntegrationHelper.establish_default_connection! + RbacHelper.setup_connects_to!(@config) + + force_drop_database(tenant) + + # Callable app_role: logs the call, then switches into the tenant DB to + # issue grants. For database-per-tenant PG, grant_tenant_privileges runs + # on the default DB connection — the callable must Tenant.switch to reach + # the tenant database's public schema. + @grant_log = [] + callable = lambda { |t, conn| + @grant_log << { tenant: t, user: conn.execute('SELECT current_user AS cu').first['cu'] } + Apartment::Tenant.switch(t) do + tc = ActiveRecord::Base.connection + role = tc.quote_table_name(RbacHelper::ROLES[:app_user]) + # Tables: DML access + tc.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO #{role}") + tc.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO #{role}" + ) + # Sequences: needed for serial/identity columns (INSERT calls nextval) + tc.execute("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO #{role}") + tc.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO #{role}") + end + } + + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [tenant] } + c.default_tenant = @config['database'] + c.app_role = callable + c.check_pending_migrations = false + end + + require 'apartment/adapters/postgresql_database_adapter' + Apartment.adapter = Apartment::Adapters::PostgresqlDatabaseAdapter.new( + @config.transform_keys(&:to_sym) + ) + Apartment.activate! + + # Create tenant under :db_manager role (production pattern via connects_to). + # This creates pool "tenant:db_manager" with db_manager credentials. + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment.adapter.create(tenant) + end + + # Revoke DDL from PUBLIC in the tenant DB. Runs as :writing role (postgres/ + # superuser) which creates a SEPARATE pool "tenant:writing" — no collision + # with the db_manager pool cached above. + # db_manager can't REVOKE because it doesn't own the public schema + # (inherited from template1, owned by postgres). + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute('REVOKE CREATE ON SCHEMA public FROM PUBLIC') + end + + # Create a test table as db_manager inside the tenant database. + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute(<<~SQL.squish) + CREATE TABLE widgets (id serial PRIMARY KEY, name varchar(255)) + SQL + end + end + end + + after do + RbacHelper.teardown_rbac_connections! + ActiveRecord::Base.connection_handler.clear_all_connections! + ActiveRecord::Base.establish_connection(@config) + force_drop_database(tenant) + Apartment.clear_config + Apartment::Current.reset + end + + it 'invokes the callable with tenant name and a live connection' do + expect(@grant_log.size).to(eq(1)) + expect(@grant_log.first[:tenant]).to(eq(tenant)) + expect(@grant_log.first[:user]).to(eq(RbacHelper::ROLES[:db_manager])) + end + + it 'app_user can DML on tables in the tenant database' do + RbacHelper.connect_as(:app_user, 'database' => tenant) + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO widgets (name) VALUES ('test')") + val = conn.select_value('SELECT name FROM widgets') + expect(val).to(eq('test')) + ensure + RbacHelper.restore_default_connection! + end + + it 'app_user cannot CREATE TABLE in the tenant database' do + RbacHelper.connect_as(:app_user, 'database' => tenant) + expect do + ActiveRecord::Base.connection.execute('CREATE TABLE forbidden (id serial)') + end.to(raise_error(ActiveRecord::StatementInvalid, /permission denied/)) + ensure + RbacHelper.restore_default_connection! + end + + it 'app_user can DML on tables created after initial grants (default privileges)' do + # db_manager creates a new table after tenant creation + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute(<<~SQL.squish) + CREATE TABLE gadgets (id serial PRIMARY KEY, label varchar(255)) + SQL + end + end + + # app_user can DML on the new table via ALTER DEFAULT PRIVILEGES + RbacHelper.connect_as(:app_user, 'database' => tenant) + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO gadgets (label) VALUES ('shiny')") + val = conn.select_value('SELECT label FROM gadgets') + expect(val).to(eq('shiny')) + ensure + RbacHelper.restore_default_connection! + end +end diff --git a/spec/integration/v4/postgresql_database_spec.rb b/spec/integration/v4/postgresql_database_spec.rb new file mode 100644 index 00000000..56b7dc66 --- /dev/null +++ b/spec/integration/v4/postgresql_database_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +unless defined?(Rails) + module Rails + def self.env + 'test' + end + end +end + +RSpec.describe('v4 PostgreSQL database-per-tenant integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.postgresql? ? false : 'requires PostgreSQL')) do + include V4IntegrationHelper + + # Force-drop a PG database by terminating active connections first. + # Safe to call even if the database does not exist. + def force_drop_database(db_name) + conn = ActiveRecord::Base.connection + conn.execute(<<~SQL.squish) + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '#{db_name}' AND pid <> pg_backend_pid() + SQL + conn.execute("DROP DATABASE IF EXISTS #{conn.quote_table_name(db_name)}") + rescue StandardError => e + warn "force_drop_database(#{db_name}): #{e.message}" + end + + # All database names this spec may create (including environmentified variants). + # rubocop:disable Lint/ConstantDefinitionInBlock + ALL_TEST_DBS = %w[ + apt_db_tenant apt_db_drop_test apt_db_dup + apt_db_iso_a apt_db_iso_b + test_apt_env_tenant + ].freeze + # rubocop:enable Lint/ConstantDefinitionInBlock + + let(:created_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! + @config = V4IntegrationHelper.establish_default_connection! + + # Pre-clean any leftover databases from prior runs + ALL_TEST_DBS.each { |db| force_drop_database(db) } + + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [] } + c.default_tenant = @config['database'] # e.g. 'apartment_v4_test' + c.check_pending_migrations = false + end + + require 'apartment/adapters/postgresql_database_adapter' + Apartment.adapter = Apartment::Adapters::PostgresqlDatabaseAdapter.new( + @config.transform_keys(&:to_sym) + ) + Apartment.activate! + end + + after do + # Disconnect all pools so DROP DATABASE succeeds + ActiveRecord::Base.connection_handler.clear_all_connections! + ActiveRecord::Base.establish_connection(@config) + + ALL_TEST_DBS.each { |db| force_drop_database(db) } + + Apartment.clear_config + Apartment::Current.reset + end + + describe 'database creation' do + it 'creates a tenant database visible in pg_database' do + Apartment.adapter.create('apt_db_tenant') + created_tenants << 'apt_db_tenant' + + exists = ActiveRecord::Base.connection.select_value( + "SELECT 1 FROM pg_database WHERE datname = 'apt_db_tenant'" + ) + expect(exists).to(eq(1)) + end + end + + describe 'database drop' do + it 'removes the tenant database from pg_database' do + Apartment.adapter.create('apt_db_drop_test') + + exists_before = ActiveRecord::Base.connection.select_value( + "SELECT 1 FROM pg_database WHERE datname = 'apt_db_drop_test'" + ) + expect(exists_before).to(eq(1)) + + Apartment.adapter.drop('apt_db_drop_test') + + exists_after = ActiveRecord::Base.connection.select_value( + "SELECT 1 FROM pg_database WHERE datname = 'apt_db_drop_test'" + ) + expect(exists_after).to(be_nil) + end + end + + describe 'double create raises TenantExists' do + it 'raises Apartment::TenantExists on duplicate create' do + Apartment.adapter.create('apt_db_dup') + created_tenants << 'apt_db_dup' + + expect do + Apartment.adapter.create('apt_db_dup') + end.to(raise_error(Apartment::TenantExists)) + end + end + + describe 'data isolation across databases' do + before do + %w[apt_db_iso_a apt_db_iso_b].each do |tenant| + Apartment.adapter.create(tenant) + created_tenants << tenant + + Apartment::Tenant.switch(tenant) do + V4IntegrationHelper.create_test_table!('widgets') + end + end + end + + it 'isolates records between tenant databases' do + Apartment::Tenant.switch('apt_db_iso_a') do + ActiveRecord::Base.connection.execute("INSERT INTO widgets (name) VALUES ('from_a')") + end + + Apartment::Tenant.switch('apt_db_iso_b') do + count = ActiveRecord::Base.connection.select_value('SELECT COUNT(*) FROM widgets') + expect(count.to_i).to(eq(0)) + end + + Apartment::Tenant.switch('apt_db_iso_a') do + count = ActiveRecord::Base.connection.select_value('SELECT COUNT(*) FROM widgets') + expect(count.to_i).to(eq(1)) + end + end + end + + describe 'resolve_connection_config' do + it 'returns config with the correct database value' do + resolved = Apartment.adapter.resolve_connection_config('acme') + expect(resolved).to(be_a(Hash)) + expect(resolved['database']).to(eq('acme')) + end + end + + describe 'environmentified database names' do + it 'creates a database prefixed with Rails environment' do + Apartment.clear_config + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [] } + c.default_tenant = @config['database'] + c.environmentify_strategy = :prepend + c.check_pending_migrations = false + end + + Apartment.adapter = Apartment::Adapters::PostgresqlDatabaseAdapter.new( + @config.transform_keys(&:to_sym) + ) + Apartment.activate! + + Apartment.adapter.create('apt_env_tenant') + created_tenants << 'apt_env_tenant' + + exists = ActiveRecord::Base.connection.select_value( + "SELECT 1 FROM pg_database WHERE datname = 'test_apt_env_tenant'" + ) + expect(exists).to(eq(1)) + end + + it 'returns environmentified database in resolve_connection_config' do + Apartment.clear_config + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { [] } + c.default_tenant = @config['database'] + c.environmentify_strategy = :prepend + c.check_pending_migrations = false + end + + Apartment.adapter = Apartment::Adapters::PostgresqlDatabaseAdapter.new( + @config.transform_keys(&:to_sym) + ) + + resolved = Apartment.adapter.resolve_connection_config('acme') + expect(resolved['database']).to(eq('test_acme')) + end + end +end diff --git a/spec/integration/v4/postgresql_schema_spec.rb b/spec/integration/v4/postgresql_schema_spec.rb new file mode 100644 index 00000000..5a9ac7ba --- /dev/null +++ b/spec/integration/v4/postgresql_schema_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 PostgreSQL schema integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.postgresql? ? false : 'requires PostgreSQL')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_pg_schema') } + let(:created_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + end + + after do + V4IntegrationHelper.cleanup_tenants!(created_tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + it 'sets search_path to the tenant schema within a switch block' do + %w[schema_a schema_b].each do |name| + Apartment.adapter.create(name) + created_tenants << name + end + + Apartment::Tenant.switch('schema_a') do + search_path = ActiveRecord::Base.connection.select_value('SHOW search_path') + expect(search_path).to(start_with('"schema_a"').or(start_with('schema_a'))) + end + + Apartment::Tenant.switch('schema_b') do + search_path = ActiveRecord::Base.connection.select_value('SHOW search_path') + expect(search_path).to(start_with('"schema_b"').or(start_with('schema_b'))) + end + end + + context 'with persistent_schemas configured' do + before do + config = V4IntegrationHelper.default_connection_config + + # Create the extensions schema if it does not exist + ActiveRecord::Base.connection.execute('CREATE SCHEMA IF NOT EXISTS extensions') + + Apartment.clear_config + Apartment::Current.reset + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.check_pending_migrations = false + c.configure_postgres do |pg| + pg.persistent_schemas = ['extensions'] + end + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + end + + after do + ActiveRecord::Base.connection.execute('DROP SCHEMA IF EXISTS extensions CASCADE') + end + + it 'includes persistent schemas in the search_path after the tenant schema' do + Apartment.adapter.create('persistent_test') + created_tenants << 'persistent_test' + + Apartment::Tenant.switch('persistent_test') do + search_path = ActiveRecord::Base.connection.select_value('SHOW search_path') + # search_path should contain both the tenant and the persistent schema + expect(search_path).to(include('persistent_test')) + expect(search_path).to(include('extensions')) + end + end + end + + it 'isolates data between schema tenants' do + %w[data_a data_b].each do |name| + Apartment.adapter.create(name) + created_tenants << name + + Apartment::Tenant.switch(name) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + + Apartment::Tenant.switch('data_a') do + ActiveRecord::Base.connection.execute("INSERT INTO widgets (name) VALUES ('from_a')") + end + + Apartment::Tenant.switch('data_b') do + count = ActiveRecord::Base.connection.select_value('SELECT COUNT(*) FROM widgets') + expect(count.to_i).to(eq(0)) + end + + Apartment::Tenant.switch('data_a') do + count = ActiveRecord::Base.connection.select_value('SELECT COUNT(*) FROM widgets') + expect(count.to_i).to(eq(1)) + end + end + + it 'creates the schema in information_schema.schemata' do + Apartment.adapter.create('ddl_check') + created_tenants << 'ddl_check' + + schemas = ActiveRecord::Base.connection.select_values( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'ddl_check'" + ) + expect(schemas).to(include('ddl_check')) + end + + it 'drops the schema and all its tables via CASCADE' do + Apartment.adapter.create('drop_me') + + Apartment::Tenant.switch('drop_me') do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + + Apartment.adapter.drop('drop_me') + + schemas = ActiveRecord::Base.connection.select_values( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'drop_me'" + ) + expect(schemas).to(be_empty) + end + + it 'prefixes excluded model table_name with "public." for schema strategy' do + V4IntegrationHelper.create_test_table!('global_settings', connection: ActiveRecord::Base.connection) + + stub_const('GlobalSetting', Class.new(ActiveRecord::Base) do + self.table_name = 'global_settings' + end) + + config = V4IntegrationHelper.default_connection_config + + Apartment.clear_config + Apartment::Current.reset + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.excluded_models = ['GlobalSetting'] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + Apartment::Tenant.init + + expect(GlobalSetting.table_name).to(eq('public.global_settings')) + end + + it 'keeps independent data in the same table name across schemas' do + %w[indie_a indie_b].each do |name| + Apartment.adapter.create(name) + created_tenants << name + + Apartment::Tenant.switch(name) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + + Apartment::Tenant.switch('indie_a') do + ActiveRecord::Base.connection.execute("INSERT INTO widgets (name) VALUES ('alpha')") + ActiveRecord::Base.connection.execute("INSERT INTO widgets (name) VALUES ('alpha2')") + end + + Apartment::Tenant.switch('indie_b') do + ActiveRecord::Base.connection.execute("INSERT INTO widgets (name) VALUES ('beta')") + end + + Apartment::Tenant.switch('indie_a') do + names = ActiveRecord::Base.connection.select_values('SELECT name FROM widgets ORDER BY name') + expect(names).to(eq(%w[alpha alpha2])) + end + + Apartment::Tenant.switch('indie_b') do + names = ActiveRecord::Base.connection.select_values('SELECT name FROM widgets ORDER BY name') + expect(names).to(eq(%w[beta])) + end + end +end diff --git a/spec/integration/v4/rbac_grants_spec.rb b/spec/integration/v4/rbac_grants_spec.rb new file mode 100644 index 00000000..884b6b5b --- /dev/null +++ b/spec/integration/v4/rbac_grants_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' + +RSpec.describe('PostgreSQL RBAC privilege grants', :integration, :postgresql_only, :rbac, + skip: (V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.postgresql? ? false : 'requires PG')) do + include V4IntegrationHelper + + let(:tenant) { 'rbac_grants_tenant' } + + before do + config = V4IntegrationHelper.establish_default_connection! + + # Create tenant as db_manager (owns the schema) with app_role grants + RbacHelper.connect_as(:db_manager) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [tenant] } + c.default_tenant = 'public' + c.app_role = RbacHelper::ROLES[:app_user] + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter( + config.merge('username' => RbacHelper::ROLES[:db_manager]) + ) + Apartment.activate! + Apartment.adapter.create(tenant) + + # Create a test table as db_manager (inside the tenant schema) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute(<<~SQL.squish) + CREATE TABLE #{ActiveRecord::Base.connection.quote_table_name(tenant)}.widgets ( + id serial PRIMARY KEY, + name varchar(255) + ) + SQL + end + + RbacHelper.restore_default_connection! + end + + after do + RbacHelper.teardown_rbac_connections! + V4IntegrationHelper.establish_default_connection! + Apartment.adapter = V4IntegrationHelper.build_adapter( + V4IntegrationHelper.default_connection_config + ) + V4IntegrationHelper.cleanup_tenants!([tenant], Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + context 'as app_user' do + before { RbacHelper.connect_as(:app_user) } + after { RbacHelper.restore_default_connection! } + + it 'can SELECT, INSERT, UPDATE, DELETE' do + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO #{conn.quote_table_name(tenant)}.widgets (name) VALUES ('test')") + + result = conn.execute("SELECT name FROM #{conn.quote_table_name(tenant)}.widgets") + expect(result.first['name']).to(eq('test')) + + conn.execute("UPDATE #{conn.quote_table_name(tenant)}.widgets SET name = 'updated'") + + result = conn.execute("SELECT name FROM #{conn.quote_table_name(tenant)}.widgets") + expect(result.first['name']).to(eq('updated')) + + conn.execute("DELETE FROM #{conn.quote_table_name(tenant)}.widgets") + result = conn.execute("SELECT count(*) AS c FROM #{conn.quote_table_name(tenant)}.widgets") + expect(result.first['c'].to_i).to(eq(0)) + end + + it 'cannot CREATE TABLE in the tenant schema' do + expect do + ActiveRecord::Base.connection.execute( + "CREATE TABLE #{ActiveRecord::Base.connection.quote_table_name(tenant)}.forbidden (id serial)" + ) + end.to(raise_error(ActiveRecord::StatementInvalid, /permission denied/)) + end + + it 'cannot DROP SCHEMA' do + expect do + ActiveRecord::Base.connection.execute( + "DROP SCHEMA #{ActiveRecord::Base.connection.quote_table_name(tenant)} CASCADE" + ) + end.to(raise_error(ActiveRecord::StatementInvalid, /must be owner|permission denied/)) + end + end + + context 'ALTER DEFAULT PRIVILEGES' do + it 'grants DML on tables created after initial tenant creation' do + # As db_manager: create a new table after the tenant was created + RbacHelper.connect_as(:db_manager) + ActiveRecord::Base.connection.execute(<<~SQL.squish) + CREATE TABLE #{ActiveRecord::Base.connection.quote_table_name(tenant)}.gadgets ( + id serial PRIMARY KEY, + label varchar(255) + ) + SQL + RbacHelper.restore_default_connection! + + # As app_user: verify DML works on the new table + RbacHelper.connect_as(:app_user) + conn = ActiveRecord::Base.connection + conn.execute("INSERT INTO #{conn.quote_table_name(tenant)}.gadgets (label) VALUES ('shiny')") + result = conn.execute("SELECT label FROM #{conn.quote_table_name(tenant)}.gadgets") + expect(result.first['label']).to(eq('shiny')) + ensure + RbacHelper.restore_default_connection! + end + end + + context 'function execute grant' do + it 'app_user can execute functions created by db_manager' do + # db_manager creates a function in the tenant schema + RbacHelper.connect_as(:db_manager) + conn = ActiveRecord::Base.connection + conn.execute(<<~SQL.squish) + CREATE FUNCTION #{conn.quote_table_name(tenant)}.rbac_test_fn() + RETURNS integer LANGUAGE sql AS 'SELECT 42' + SQL + RbacHelper.restore_default_connection! + + # app_user can call it (via ALTER DEFAULT PRIVILEGES ... ON FUNCTIONS) + RbacHelper.connect_as(:app_user) + result = ActiveRecord::Base.connection.execute( + "SELECT #{ActiveRecord::Base.connection.quote_table_name(tenant)}.rbac_test_fn() AS val" + ) + expect(result.first['val'].to_i).to(eq(42)) + ensure + RbacHelper.restore_default_connection! + end + end + + context 'as db_manager' do + before { RbacHelper.connect_as(:db_manager) } + after { RbacHelper.restore_default_connection! } + + it 'can CREATE TABLE and DROP SCHEMA' do + conn = ActiveRecord::Base.connection + conn.execute( + "CREATE TABLE #{conn.quote_table_name(tenant)}.temp_table (id serial PRIMARY KEY)" + ) + conn.execute("DROP TABLE #{conn.quote_table_name(tenant)}.temp_table") + # Verify full DDL: db_manager can drop the schema it owns + conn.execute("DROP SCHEMA #{conn.quote_table_name(tenant)} CASCADE") + # Recreate for cleanup consistency + conn.execute("CREATE SCHEMA #{conn.quote_table_name(tenant)}") + end + end +end diff --git a/spec/integration/v4/request_lifecycle_spec.rb b/spec/integration/v4/request_lifecycle_spec.rb new file mode 100644 index 00000000..425cfc87 --- /dev/null +++ b/spec/integration/v4/request_lifecycle_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Request lifecycle tests require the dummy Rails app + real PostgreSQL. +# Run via: DATABASE_ENGINE=postgresql bundle exec appraisal rails-8.1-postgresql \ +# rspec spec/integration/v4/request_lifecycle_spec.rb + +require 'spec_helper' + +DUMMY_APP_AVAILABLE = begin + require_relative('../../dummy/config/environment') + require('rack/test') + true +rescue LoadError, StandardError => e + warn "[request_lifecycle_spec] Skipping: #{e.message}" + false +end + +RSpec.describe('v4 Request lifecycle', :request_lifecycle, + skip: (DUMMY_APP_AVAILABLE ? false : 'requires dummy Rails app + PostgreSQL')) do + include Rack::Test::Methods + + def app + Rails.application + end + + before(:all) do + # Ensure test schemas exist + %w[acme widgets].each do |tenant| + Apartment.adapter.create(tenant) + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.create_table(:users, force: true) do |t| + t.string(:name) + end + end + rescue Apartment::TenantExists + nil + end + end + + after(:all) do + %w[acme widgets].each do |tenant| + Apartment.adapter.drop(tenant) + rescue StandardError + nil + end + end + + after do + Apartment::Current.reset + end + + it 'elevator switches tenant based on subdomain' do + header 'Host', 'acme.example.com' + get '/tenant_info' + expect(last_response).to(be_ok) + body = JSON.parse(last_response.body) + expect(body['tenant']).to(eq('acme')) + end + + it 'data is isolated between tenants' do + Apartment::Tenant.switch('acme') { User.create!(name: 'Alice') } + + header 'Host', 'acme.example.com' + get '/tenant_info' + expect(JSON.parse(last_response.body)['user_count']).to(eq(1)) + + header 'Host', 'widgets.example.com' + get '/tenant_info' + expect(JSON.parse(last_response.body)['user_count']).to(eq(0)) + end + + it 'tenant context is cleaned up after request' do + header 'Host', 'acme.example.com' + get '/tenant_info' + # After request completes, tenant should be reset to default + expect(Apartment::Tenant.current).to(eq('public')) + end + + it 'returns default tenant for requests without subdomain' do + header 'Host', 'example.com' + get '/tenant_info' + expect(last_response).to(be_ok) + body = JSON.parse(last_response.body) + expect(body['tenant']).to(eq('public')) + end + + context 'with Header elevator' do + around do |example| + Rails.application.middleware.use(Apartment::Elevators::Header, header: 'X-Tenant-Id') + example.run + ensure + Rails.application.middleware.delete(Apartment::Elevators::Header) + end + + it 'switches tenant based on X-Tenant-Id header' do + header 'X-Tenant-Id', 'acme' + header 'Host', 'example.com' + get '/tenant_info' + expect(last_response).to(be_ok) + body = JSON.parse(last_response.body) + expect(body['tenant']).to(eq('acme')) + end + + it 'falls through to default tenant when header is absent' do + header 'Host', 'example.com' + get '/tenant_info' + expect(last_response).to(be_ok) + body = JSON.parse(last_response.body) + expect(body['tenant']).to(eq('public')) + end + end +end diff --git a/spec/integration/v4/role_aware_connection_spec.rb b/spec/integration/v4/role_aware_connection_spec.rb new file mode 100644 index 00000000..23590f30 --- /dev/null +++ b/spec/integration/v4/role_aware_connection_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' +require_relative 'support/rbac_helper' + +RSpec.describe('Role-aware connection routing', :integration, :postgresql_only, :rbac, + skip: (V4_INTEGRATION_AVAILABLE && V4IntegrationHelper.postgresql? ? false : 'requires PG')) do + include V4IntegrationHelper + + let(:tenant) { 'rbac_conn_tenant' } + + before do + config = V4IntegrationHelper.establish_default_connection! + RbacHelper.setup_connects_to!(config) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [tenant] } + c.default_tenant = 'public' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + Apartment.adapter.create(tenant) + end + + after do + RbacHelper.teardown_rbac_connections! + V4IntegrationHelper.establish_default_connection! + Apartment.adapter = V4IntegrationHelper.build_adapter( + V4IntegrationHelper.default_connection_config + ) + V4IntegrationHelper.cleanup_tenants!([tenant], Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + it 'creates separate pools per role for the same tenant' do + writing_pool = nil + writing_user = nil + + Apartment::Tenant.switch(tenant) do + writing_pool = ActiveRecord::Base.connection_pool + writing_user = ActiveRecord::Base.connection.execute('SELECT current_user AS cu').first['cu'] + end + + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) do + mgr_pool = ActiveRecord::Base.connection_pool + mgr_user = ActiveRecord::Base.connection.execute('SELECT current_user AS cu').first['cu'] + + expect(mgr_pool).not_to(eq(writing_pool)) + expect(mgr_user).to(eq(RbacHelper::ROLES[:db_manager])) + expect(writing_user).not_to(eq(mgr_user)) + end + end + end + + it 'uses distinct pool keys per role' do + Apartment::Tenant.switch(tenant) { ActiveRecord::Base.connection } + + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) { ActiveRecord::Base.connection } + end + + pool_keys = Apartment.pool_manager.stats[:tenants] + expect(pool_keys).to(include("#{tenant}:writing")) + expect(pool_keys).to(include("#{tenant}:db_manager")) + end + + it 'propagates the db_manager username into tenant pool config' do + ActiveRecord::Base.connected_to(role: :db_manager) do + Apartment::Tenant.switch(tenant) do + pool_config = ActiveRecord::Base.connection_pool.db_config.configuration_hash + expect(pool_config[:username]).to(eq(RbacHelper::ROLES[:db_manager])) + end + end + end +end diff --git a/spec/integration/v4/scenarios/mysql_database.yml b/spec/integration/v4/scenarios/mysql_database.yml new file mode 100644 index 00000000..db043bac --- /dev/null +++ b/spec/integration/v4/scenarios/mysql_database.yml @@ -0,0 +1,12 @@ +name: mysql_database +engine: mysql +strategy: database_name +adapter_class: Mysql2Adapter +default_tenant: apartment_v4_test +connection: + adapter: mysql2 + host: <%= ENV.fetch('MYSQL_HOST', '127.0.0.1') %> + port: <%= ENV.fetch('MYSQL_PORT', '3306') %> + username: <%= ENV.fetch('MYSQL_USER', 'root') %> + password: <%= ENV.fetch('MYSQL_PASSWORD', nil) %> + database: <%= ENV.fetch('APARTMENT_TEST_MYSQL_DB', 'apartment_v4_test') %> diff --git a/spec/integration/v4/scenarios/postgresql_database.yml b/spec/integration/v4/scenarios/postgresql_database.yml new file mode 100644 index 00000000..89bb8ef1 --- /dev/null +++ b/spec/integration/v4/scenarios/postgresql_database.yml @@ -0,0 +1,12 @@ +name: postgresql_database +engine: postgresql +strategy: database_name +adapter_class: PostgresqlDatabaseAdapter +default_tenant: apartment_v4_test +connection: + adapter: postgresql + host: <%= ENV.fetch('PGHOST', '127.0.0.1') %> + port: <%= ENV.fetch('PGPORT', '5432') %> + username: <%= ENV.fetch('PGUSER', ENV.fetch('USER', nil)) %> + password: <%= ENV.fetch('PGPASSWORD', nil) %> + database: <%= ENV.fetch('APARTMENT_TEST_PG_DB', 'apartment_v4_test') %> diff --git a/spec/integration/v4/scenarios/postgresql_schema.yml b/spec/integration/v4/scenarios/postgresql_schema.yml new file mode 100644 index 00000000..32d759a5 --- /dev/null +++ b/spec/integration/v4/scenarios/postgresql_schema.yml @@ -0,0 +1,12 @@ +name: postgresql_schema +engine: postgresql +strategy: schema +adapter_class: PostgresqlSchemaAdapter +default_tenant: public +connection: + adapter: postgresql + host: <%= ENV.fetch('PGHOST', '127.0.0.1') %> + port: <%= ENV.fetch('PGPORT', '5432') %> + username: <%= ENV.fetch('PGUSER', ENV.fetch('USER', nil)) %> + password: <%= ENV.fetch('PGPASSWORD', nil) %> + database: <%= ENV.fetch('APARTMENT_TEST_PG_DB', 'apartment_v4_test') %> diff --git a/spec/integration/v4/scenarios/sqlite_file.yml b/spec/integration/v4/scenarios/sqlite_file.yml new file mode 100644 index 00000000..b03c0980 --- /dev/null +++ b/spec/integration/v4/scenarios/sqlite_file.yml @@ -0,0 +1,8 @@ +name: sqlite_file +engine: sqlite +strategy: database_name +adapter_class: Sqlite3Adapter +default_tenant: default +connection: + adapter: sqlite3 + database: <%= ENV.fetch('APARTMENT_TEST_SQLITE_DB', 'tmp/apartment_v4_test.sqlite3') %> diff --git a/spec/integration/v4/stress_spec.rb b/spec/integration/v4/stress_spec.rb new file mode 100644 index 00000000..b519b7f2 --- /dev/null +++ b/spec/integration/v4/stress_spec.rb @@ -0,0 +1,695 @@ +# frozen_string_literal: true + +# rubocop:disable ThreadSafety/NewThread, Style/CombinableLoops + +require 'spec_helper' +require_relative 'support' +require 'concurrent' + +RSpec.describe('v4 Stress / concurrency integration', :integration, :stress, + skip: if !V4_INTEGRATION_AVAILABLE + 'requires ActiveRecord + database gem' + elsif V4IntegrationHelper.sqlite? + 'SQLite single-writer lock causes BusyException under concurrent threads' + end) do + include V4IntegrationHelper + + # ── Concurrent switching ──────────────────────────────────────────── + context 'concurrent switching' do + let(:tmp_dir) { Dir.mktmpdir('apartment_stress') } + let(:tenants) { Array.new(5) { |i| "stress_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + # Bump pool size so 10 concurrent threads can share a single tenant pool. + # Re-establish default connection with bumped pool so role-aware + # ConnectionHandling (which resolves base config from the default pool) + # propagates the larger pool size to tenant pools. + config = config.merge('pool' => 15) + ActiveRecord::Base.establish_connection(config) + V4IntegrationHelper.create_test_table! + + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) { V4IntegrationHelper.create_test_table! } + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'maintains data isolation across 10 threads doing 50 switches each' do + errors = Queue.new + + threads = Array.new(10) do |thread_idx| + Thread.new do + 50.times do + tenant = tenants.sample + Apartment::Tenant.switch(tenant) do + Widget.create!(name: "thread_#{thread_idx}") + end + end + rescue StandardError => e + errors << "Thread #{thread_idx}: #{e.class}: #{e.message}" + end + end + threads.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + # Sum across all tenants should equal 500 (10 threads * 50 writes) + total = tenants.sum do |t| + Apartment::Tenant.switch(t) { Widget.count } + end + expect(total).to(eq(500)) + end + + it 'concurrent pool creation for the same tenant does not corrupt state' do + pools = Concurrent::Array.new + barrier = Concurrent::CyclicBarrier.new(10) + + threads = Array.new(10) do + Thread.new do + barrier.wait + Apartment::Tenant.switch('stress_0') do + pools << ActiveRecord::Base.connection_pool.object_id + ActiveRecord::Base.connection.execute('SELECT 1') + end + end + end + threads.each(&:join) + + # All threads should have gotten the same pool (fetch_or_create is idempotent) + expect(pools.uniq.size).to(eq(1)) + end + + it 'each thread sees correct Tenant.current inside switch block' do + barrier = Concurrent::CyclicBarrier.new(5) + results = Concurrent::Map.new + errors = Queue.new + + threads = tenants.map.with_index do |tenant, idx| + Thread.new do + barrier.wait + Apartment::Tenant.switch(tenant) do + results[idx] = { + tenant_current: Apartment::Tenant.current, + current_tenant: Apartment::Current.tenant, + } + end + rescue StandardError => e + errors << "Thread #{idx}: #{e.class}: #{e.message}" + end + end + threads.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + tenants.each_with_index do |tenant, idx| + expect(results[idx]).not_to(be_nil, "Thread #{idx} produced no result") + actual_current = results[idx][:tenant_current] + actual_attr = results[idx][:current_tenant] + expect(actual_current).to(eq(tenant), + "Thread #{idx}: Tenant.current was '#{actual_current}', expected '#{tenant}'") + expect(actual_attr).to(eq(tenant), + "Thread #{idx}: Current.tenant was '#{actual_attr}', expected '#{tenant}'") + end + end + + it 'cross-tenant connection checkout returns only own tenant data' do + barrier = Concurrent::CyclicBarrier.new(2) + results = Concurrent::Map.new + errors = Queue.new + + %w[stress_0 stress_1].map.with_index do |tenant, idx| + Thread.new do + barrier.wait + Apartment::Tenant.switch(tenant) do + Widget.create!(name: "isolation_#{idx}") + results[idx] = Widget.pluck(:name) + end + rescue StandardError => e + errors << "Thread #{idx}: #{e.class}: #{e.message}" + end + end.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + expect(results[0]).to(include('isolation_0')) + expect(results[0]).not_to(include('isolation_1'), + "Thread 0 (stress_0) read data from stress_1's pool") + expect(results[1]).to(include('isolation_1')) + expect(results[1]).not_to(include('isolation_0'), + "Thread 1 (stress_1) read data from stress_0's pool") + end + end + + # ── Many tenants — pool manager scales ────────────────────────────── + context 'pool manager scaling' do + let(:tmp_dir) { Dir.mktmpdir('apartment_scale') } + let(:many_tenants) { Array.new(50) { |i| "scale_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + @config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { many_tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(@config) + Apartment.activate! + end + + after do + many_tenants.each do |t| + Apartment.adapter.drop(t) + rescue StandardError + nil + end + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'handles 50 tenants without pool corruption' do + many_tenants.each { |t| Apartment.adapter.create(t) } + + many_tenants.each do |t| + Apartment::Tenant.switch(t) do + V4IntegrationHelper.create_test_table! + Widget.create!(name: t) + end + end + + many_tenants.each do |t| + Apartment::Tenant.switch(t) do + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq(t)) + end + end + + expect(Apartment.pool_manager.stats[:total_pools]).to(eq(50)) + end + end + + # ── PoolReaper evicts idle pools ──────────────────────────────────── + context 'pool reaper' do + let(:tmp_dir) { Dir.mktmpdir('apartment_reaper') } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + end + + after do + begin + Apartment.adapter&.drop('reap_me') + rescue StandardError + nil + end + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'evicts idle pools after timeout' do + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { %w[reap_me] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.pool_idle_timeout = 0.5 + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + Apartment.adapter.create('reap_me') + + Apartment::Tenant.switch('reap_me') do + ActiveRecord::Base.connection.execute('SELECT 1') + end + + role = ActiveRecord::Base.current_role + expect(Apartment.pool_manager.tracked?("reap_me:#{role}")).to(be(true)) + + # Poll until reaper evicts the idle pool or timeout + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5 + until !Apartment.pool_manager.tracked?("reap_me:#{role}") || + Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep(0.1) + end + + expect(Apartment.pool_manager.tracked?("reap_me:#{role}")).to(be(false)) + end + end + + # ── PG: search_path isolation across concurrent threads ───────────── + context 'PostgreSQL search_path isolation', if: V4IntegrationHelper.postgresql? do + let(:tmp_dir) { Dir.mktmpdir('apartment_pg_sp') } + let(:tenants) { Array.new(5) { |i| "sp_iso_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + config = config.merge('pool' => 15) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { tenants } + c.default_tenant = 'public' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) { V4IntegrationHelper.create_test_table! } + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + it 'each thread sees its own schema in search_path' do + barrier = Concurrent::CyclicBarrier.new(5) + results = Concurrent::Map.new + errors = Queue.new + + threads = tenants.map.with_index do |tenant, idx| + Thread.new do + barrier.wait + Apartment::Tenant.switch(tenant) do + sp = ActiveRecord::Base.connection.execute('SHOW search_path').first + # PG returns { "search_path" => "..." } — extract the value + search_path_value = sp.is_a?(Hash) ? sp.values.first : sp.first + results[idx] = { tenant: tenant, search_path: search_path_value } + end + rescue StandardError => e + errors << "Thread #{idx}: #{e.class}: #{e.message}" + end + end + threads.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + # Each thread should see its own schema in the search_path + tenants.each_with_index do |tenant, idx| + expect(results[idx]).not_to(be_nil, "Thread #{idx} produced no result") + actual_sp = results[idx][:search_path] + expect(actual_sp).to(include(tenant), + "Thread #{idx}: expected '#{tenant}' in search_path, got '#{actual_sp}'") + end + end + end + + # ── PG: concurrent schema creation ──────────────────────────────── + context 'PostgreSQL concurrent schema creation', if: V4IntegrationHelper.postgresql? do + let(:tmp_dir) { Dir.mktmpdir('apartment_pg_csc') } + let(:tenants) { Array.new(10) { |i| "csc_schema_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + ActiveRecord::Base.establish_connection(config.merge('pool' => 25)) + + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { tenants } + c.default_tenant = 'public' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config.merge('pool' => 25)) + Apartment.activate! + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + it 'creates 10 schemas concurrently without errors' do + barrier = Concurrent::CyclicBarrier.new(10) + errors = Queue.new + + threads = tenants.map do |tenant| + Thread.new do + barrier.wait + Apartment.adapter.create(tenant) + rescue StandardError => e + errors << "#{tenant}: #{e.class}: #{e.message}" + end + end + threads.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + # Verify all schemas exist via information_schema + existing = ActiveRecord::Base.connection.execute( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'csc_schema_%'" + ).map { |row| row.is_a?(Hash) ? row['schema_name'] : row.first } + + tenants.each do |t| + expect(existing).to(include(t), "Schema '#{t}' was not created") + end + end + end + + # ── MySQL: rapid switching without connection exhaustion ─────────── + context 'MySQL rapid switching', if: V4IntegrationHelper.mysql? do + let(:tmp_dir) { Dir.mktmpdir('apartment_my_rapid') } + let(:tenants) { Array.new(5) { |i| "my_rapid_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + config = config.merge('pool' => 15) + + stub_const('Widget', Class.new(ActiveRecord::Base) { self.table_name = 'widgets' }) + + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { tenants } + c.default_tenant = 'default' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |t| + Apartment.adapter.create(t) + Apartment::Tenant.switch(t) { V4IntegrationHelper.create_test_table! } + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + it 'survives 500 rapid switches without connection errors' do + errors = Queue.new + + 500.times do + tenant = tenants.sample + Apartment::Tenant.switch(tenant) do + ActiveRecord::Base.connection.execute('SELECT 1') + end + rescue StandardError => e + errors << "#{e.class}: #{e.message}" + end + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + end + end + + # ── MySQL: concurrent database creation ──────────────────────────── + context 'MySQL concurrent database creation', if: V4IntegrationHelper.mysql? do + let(:tmp_dir) { Dir.mktmpdir('apartment_my_cdc') } + let(:tenants) { Array.new(10) { |i| "my_cdc_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + ActiveRecord::Base.establish_connection(config.merge('pool' => 25)) + + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { tenants } + c.default_tenant = 'default' + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config.merge('pool' => 25)) + Apartment.activate! + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + end + + it 'creates 10 databases concurrently without errors' do + barrier = Concurrent::CyclicBarrier.new(10) + errors = Queue.new + + threads = tenants.map do |tenant| + Thread.new do + barrier.wait + Apartment.adapter.create(tenant) + rescue StandardError => e + errors << "#{tenant}: #{e.class}: #{e.message}" + end + end + threads.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + # Verify all databases exist via SHOW DATABASES + existing = ActiveRecord::Base.connection.execute('SHOW DATABASES').map do |row| + row.is_a?(Hash) ? row.values.first : row.first + end + + tenants.each do |t| + expect(existing).to(include(t), "Database '#{t}' was not created") + end + end + end + + # ── Concurrent drop while query in-flight (all engines) ─────────── + context 'concurrent drop during active query' do + let(:tmp_dir) { Dir.mktmpdir('apartment_drop_race') } + let(:tenant_name) { 'drop_race_tenant' } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + config = config.merge('pool' => 15) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { [tenant_name] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + Apartment.adapter.create(tenant_name) + Apartment::Tenant.switch(tenant_name) { V4IntegrationHelper.create_test_table! } + end + + after do + begin + Apartment.adapter&.drop(tenant_name) + rescue StandardError + nil + end + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'drop completes without crash; pool manager state is consistent' do + # Thread A: switch into tenant and hold the connection for a bit + thread_a_started = Concurrent::Event.new + thread_a_error = Concurrent::AtomicReference.new(nil) + + thread_a = Thread.new do + Apartment::Tenant.switch(tenant_name) do + thread_a_started.set + # Hold the connection open — give Thread B time to issue the drop + begin + ActiveRecord::Base.connection.execute('SELECT 1') + sleep(0.3) + # Try another query after drop may have happened + ActiveRecord::Base.connection.execute('SELECT 1') + rescue StandardError => e + # Expected on some engines: the tenant may be gone. + # Record it but don't re-raise — we just want no crash/segfault. + thread_a_error.set(e) + end + end + rescue StandardError => e + thread_a_error.set(e) + end + + # Thread B: wait for A to start, then drop the tenant + thread_b_error = Concurrent::AtomicReference.new(nil) + + thread_b = Thread.new do + thread_a_started.wait(5) # wait up to 5s for thread A + begin + Apartment.adapter.drop(tenant_name) + rescue StandardError => e + thread_b_error.set(e) + end + end + + [thread_a, thread_b].each { |t| t.join(10) } + + # The core assertion: no segfault, no hung threads — both threads completed. + # Whether the drop or the query raised is engine-dependent: + # + # PG (schemas): DROP SCHEMA CASCADE succeeds even with active connections + # using that search_path, but the pool disconnect may fail. Thread A's + # second query may raise if the schema is gone. + # MySQL: DROP DATABASE may fail with metadata lock if a connection is active. + # SQLite: File deletion may fail or succeed depending on OS file locking. + # + # We accept either outcome — the invariant is no crash and consistent state. + + drop_err = thread_b_error.get + query_err = thread_a_error.get + + # At least one of them should have succeeded without error + # (if both errored, something unexpected happened) + if drop_err && query_err + # Both errored — acceptable only if both are StandardError subclasses + expect(drop_err).to(be_a(StandardError), + "Thread B raised non-StandardError: #{drop_err.class}") + expect(query_err).to(be_a(StandardError), + "Thread A raised non-StandardError: #{query_err.class}") + end + + if drop_err.nil? + # Drop succeeded — pool manager may or may not still track the tenant. + # Known race: Thread A's switch block may re-create the pool via + # ConnectionHandling#connection_pool → fetch_or_create after Thread B + # removed it. This is a documented architectural limitation — concurrent + # drop while a switch block is active can leave an orphaned pool entry. + # The important thing is: no crash, no segfault, and the DDL completed. + else + # Drop failed — that's acceptable (e.g., MySQL metadata lock). + # Just verify the error is a StandardError (not a segfault/SystemError). + expect(drop_err).to(be_a(StandardError)) + end + end + end + + # ── Parallel tenant creation storm ────────────────────────────────── + context 'tenant creation storm' do + let(:tmp_dir) { Dir.mktmpdir('apartment_storm') } + let(:storm_tenants) { Array.new(20) { |i| "storm_#{i}" } } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + # Bump default pool size — 20 threads all do CREATE DDL via the default connection. + ActiveRecord::Base.establish_connection(config.merge('pool' => 25)) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { storm_tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config.merge('pool' => 25)) + Apartment.activate! + end + + after do + storm_tenants.each do |t| + Apartment.adapter.drop(t) + rescue StandardError + nil + end + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'handles parallel tenant creation without errors' do + errors = Queue.new + + threads = storm_tenants.map do |t| + Thread.new do + Apartment.adapter.create(t) + rescue StandardError => e + errors << "#{t}: #{e.class}: #{e.message}" + end + end + threads.each(&:join) + + collected_errors = [] + collected_errors << errors.pop until errors.empty? + expect(collected_errors).to(be_empty) + + # Verify all tenants are accessible + storm_tenants.each do |t| + Apartment::Tenant.switch(t) do + ActiveRecord::Base.connection.execute('SELECT 1') + end + end + end + end +end + +# rubocop:enable ThreadSafety/NewThread, Style/CombinableLoops diff --git a/spec/integration/v4/support.rb b/spec/integration/v4/support.rb new file mode 100644 index 00000000..8a94ff6d --- /dev/null +++ b/spec/integration/v4/support.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require('tmpdir') +require('fileutils') +require('erb') +require('yaml') + +# Integration tests require real ActiveRecord + a database gem. +# Run via appraisal: +# bundle exec appraisal rails-8.1-sqlite3 rspec spec/integration/v4/ +# bundle exec appraisal rails-8.1-postgresql rspec spec/integration/v4/ +# bundle exec appraisal rails-8.1-mysql2 rspec spec/integration/v4/ +# +# Set DATABASE_ENGINE to force an engine: postgresql, mysql, sqlite (default: sqlite) +V4_INTEGRATION_AVAILABLE = begin + require('active_record') + ActiveRecord::Base.respond_to?(:establish_connection) +rescue LoadError + false +end + +# Helpers for multi-engine integration tests. +module V4IntegrationHelper + module_function + + def database_engine + ENV.fetch('DATABASE_ENGINE', 'sqlite') + end + + def postgresql? + database_engine == 'postgresql' + end + + def mysql? + database_engine == 'mysql' + end + + def sqlite? + database_engine == 'sqlite' + end + + # Establish the default AR connection for the current engine. + # Returns the connection config hash (string keys). + def establish_default_connection!(tmp_dir: nil) + config = default_connection_config(tmp_dir: tmp_dir) + ActiveRecord::Base.establish_connection(config) + config + end + + # Build the default connection config for the current engine. + def default_connection_config(tmp_dir: nil) + case database_engine + when 'postgresql' + { + 'adapter' => 'postgresql', + 'host' => ENV.fetch('PGHOST', '127.0.0.1'), + 'port' => ENV.fetch('PGPORT', '5432').to_i, + 'username' => ENV.fetch('PGUSER', ENV.fetch('USER', nil)), + 'password' => ENV.fetch('PGPASSWORD', nil), + 'database' => ENV.fetch('APARTMENT_TEST_PG_DB', 'apartment_v4_test'), + } + when 'mysql' + { + 'adapter' => 'mysql2', + 'host' => ENV.fetch('MYSQL_HOST', '127.0.0.1'), + 'port' => ENV.fetch('MYSQL_PORT', '3306').to_i, + 'username' => ENV.fetch('MYSQL_USER', 'root'), + 'password' => ENV.fetch('MYSQL_PASSWORD', nil), + 'database' => ENV.fetch('APARTMENT_TEST_MYSQL_DB', 'apartment_v4_test'), + } + else # sqlite + { + 'adapter' => 'sqlite3', + 'database' => File.join(tmp_dir || Dir.mktmpdir('apartment_v4'), 'default.sqlite3'), + } + end + end + + # Build the v4 adapter for the current engine. + def build_adapter(connection_config) + case database_engine + when 'postgresql' + require('apartment/adapters/postgresql_schema_adapter') + Apartment::Adapters::PostgresqlSchemaAdapter.new(connection_config.transform_keys(&:to_sym)) + when 'mysql' + require('apartment/adapters/mysql2_adapter') + Apartment::Adapters::Mysql2Adapter.new(connection_config.transform_keys(&:to_sym)) + else + require('apartment/adapters/sqlite3_adapter') + Apartment::Adapters::Sqlite3Adapter.new(connection_config.transform_keys(&:to_sym)) + end + end + + # The tenant strategy for the current engine. + def tenant_strategy + postgresql? ? :schema : :database_name + end + + # The default tenant name (PG schema strategy uses 'public'). + def default_tenant + postgresql? ? 'public' : 'default' + end + + # Create a test table in the current connection. + def create_test_table!(table_name = 'widgets', connection: ActiveRecord::Base.connection) + connection.create_table(table_name, force: true) do |t| + t.string(:name) + end + end + + # Ensure the test database exists for PG/MySQL (no-op for SQLite). + def ensure_test_database! + case database_engine + when 'postgresql' + db_name = ENV.fetch('APARTMENT_TEST_PG_DB', 'apartment_v4_test') + # Connect to 'postgres' DB to create the test DB + ActiveRecord::Base.establish_connection( + default_connection_config.merge('database' => 'postgres') + ) + unless ActiveRecord::Base.connection.select_value( + "SELECT 1 FROM pg_database WHERE datname = '#{db_name}'" + ) + ActiveRecord::Base.connection.execute("CREATE DATABASE #{db_name}") + end + ActiveRecord::Base.establish_connection(default_connection_config) + when 'mysql' + db_name = ENV.fetch('APARTMENT_TEST_MYSQL_DB', 'apartment_v4_test') + # Connect without a database to create it + ActiveRecord::Base.establish_connection( + default_connection_config.merge('database' => nil) + ) + ActiveRecord::Base.connection.execute("CREATE DATABASE IF NOT EXISTS `#{db_name}`") + ActiveRecord::Base.establish_connection(default_connection_config) + end + # SQLite: no-op, file created on connect + end + + # Drop tenant schemas/databases created during tests. + def cleanup_tenants!(tenant_names, adapter) + tenant_names.each do |tenant| + adapter.drop(tenant) + rescue StandardError => e + warn "[V4IntegrationHelper] cleanup_tenants! failed for '#{tenant}': #{e.message}" + end + end + + # --- Scenario-based configs --- + + Scenario = Struct.new(:name, :engine, :strategy, :adapter_class, :default_tenant, + :connection) + + def load_scenario(name) + path = File.join(__dir__, 'scenarios', "#{name}.yml") + raise(ArgumentError, "Scenario file not found: #{path}") unless File.exist?(path) + + raw = YAML.safe_load(ERB.new(File.read(path)).result, permitted_classes: [Symbol]) + Scenario.new( + name: raw['name'], + engine: raw['engine'], + strategy: raw['strategy'].to_sym, + adapter_class: raw['adapter_class'], + default_tenant: raw['default_tenant'], + connection: raw['connection'].transform_keys(&:to_s) + ) + end + + def scenarios_for_engine + Dir[File.join(__dir__, 'scenarios', '*.yml')].filter_map do |path| + scenario_name = File.basename(path, '.yml') + scenario = load_scenario(scenario_name) + scenario if scenario.engine == database_engine + end + end + + def each_scenario(&) + scenarios_for_engine.each(&) + end +end + +if V4_INTEGRATION_AVAILABLE + RSpec.configure do |config| + # Swap ConnectionHandler per test for hermetic isolation. + # Skip for :stress-tagged tests — concurrent threads race with handler swap teardown. + config.around(:each, :integration) do |example| + if example.metadata[:stress] + example.run + next + end + + old_handler = ActiveRecord::Base.connection_handler + new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + ActiveRecord::Base.connection_handler = new_handler + + # Re-establish the default connection on the fresh handler. + default_config = V4IntegrationHelper.default_connection_config + ActiveRecord::Base.establish_connection(default_config) if default_config + + example.run + ensure + unless example.metadata[:stress] + begin + new_handler&.clear_all_connections! + rescue StandardError => e + warn "[V4IntegrationHelper] clear_all_connections! failed: #{e.message}" + end + ActiveRecord::Base.connection_handler = old_handler + end + end + end +end diff --git a/spec/integration/v4/support/rbac_helper.rb b/spec/integration/v4/support/rbac_helper.rb new file mode 100644 index 00000000..1c423385 --- /dev/null +++ b/spec/integration/v4/support/rbac_helper.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# Shared RBAC test infrastructure for integration tests that verify +# role-aware connections, privilege grants, and Migrator migration_role. +# +# Usage: tag specs with :rbac plus :postgresql_only or :mysql_only. +# Roles are provisioned once per :rbac describe block via before(:context, :rbac). +# If provisioning fails (e.g., local PG user lacks CREATEROLE), +# all :rbac specs skip with an actionable message. +module RbacHelper + ROLES = { + db_manager: 'apt_test_db_manager', + app_user: 'apt_test_app_user', + }.freeze + + @provisioned = false + @available = false + + module_function + + def provisioned? + @provisioned + end + + def available? + @available + end + + # Idempotent role creation. Engine-aware. + # Returns true on success, false on failure. + # One-shot: first failure wins for the process (no retry on subsequent :rbac contexts). + def provision_roles!(connection) + return @available if @provisioned + + @provisioned = true + engine = V4IntegrationHelper.database_engine + + case engine + when 'postgresql' + provision_pg_roles!(connection) + when 'mysql' + provision_mysql_roles!(connection) + else + warn '[RbacHelper] RBAC tests require PostgreSQL or MySQL' + return (@available = false) + end + + @available = true + rescue ActiveRecord::StatementInvalid => e + if e.message.match?(/permission denied|must be superuser|CREATEROLE|Access denied/i) + warn "[RbacHelper] Insufficient privileges to provision roles: #{e.message}" + else + warn "[RbacHelper] Unexpected error during role provisioning: #{e.message}" + end + warn '[RbacHelper] See docs/designs/v4-phase5.2-rbac-integration-tests.md for setup instructions.' + @available = false + end + + # Connect as a specific role. Stashes the original config for restoration. + # For grant verification tests (separate connections, not SET ROLE). + # Only stashes on first call — subsequent calls without restore reuse the original stash + # to prevent overwriting the real config with an already-swapped one. + def connect_as(role_key, **overrides) + username = ROLES.fetch(role_key) + @stashed_config ||= ActiveRecord::Base.connection_db_config.configuration_hash.stringify_keys + ActiveRecord::Base.establish_connection(@stashed_config.merge('username' => username, **overrides)) + end + + # Restore the connection stashed by connect_as. + # Clears the stash before reconnecting to prevent cross-test poisoning + # if establish_connection raises. + def restore_default_connection! + return unless @stashed_config + + config = @stashed_config + @stashed_config = nil + ActiveRecord::Base.establish_connection(config) + end + + # Register database configs for :writing and :db_manager roles with AR's + # ConnectionHandler. Uses the same database but different usernames. + # Must be called in before(:each), not before(:context): the :integration tag's + # around hook swaps ConnectionHandler per example, discarding any registrations + # made at the context level. + def setup_connects_to!(base_config) + handler = ActiveRecord::Base.connection_handler + + { writing: base_config, + db_manager: base_config.merge('username' => ROLES[:db_manager]) }.each do |role, config| + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + 'test', "primary_#{role}", config + ) + handler.establish_connection( + db_config, + owner_name: ActiveRecord::Base, + role: role + ) + end + end + + # Restore stashed connection if connect_as was called without restore. + # Pool cleanup is handled by the ConnectionHandler swap in the :integration around hook. + def teardown_rbac_connections! + restore_default_connection! if @stashed_config + end + + # --- Private provisioning methods --- + + def provision_pg_roles!(connection) + connection.execute(<<~SQL.squish) + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '#{ROLES[:db_manager]}') THEN + CREATE ROLE #{ROLES[:db_manager]} LOGIN CREATEDB; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '#{ROLES[:app_user]}') THEN + CREATE ROLE #{ROLES[:app_user]} LOGIN; + END IF; + END $$; + SQL + connection.execute("GRANT #{ROLES[:app_user]} TO #{ROLES[:db_manager]}") + # GRANT CREATE ON DATABASE so db_manager can create schemas. + # This runs here (not in CI provisioning) because the test database + # (apartment_v4_test) may not exist at CI role-provisioning time. + # The CI database (apartment_postgresql_test) differs from the test database. + db_name = connection.current_database + connection.execute("GRANT CREATE ON DATABASE #{connection.quote_table_name(db_name)} TO #{ROLES[:db_manager]}") + # db_manager needs full access to the public schema for migrate_primary + # (which runs under migration_role). PG 15+ revoked CREATE ON SCHEMA public + # FROM PUBLIC. We also need access to tables postgres creates (e.g., + # schema_migrations from non-RBAC specs that may run before or after + # provisioning due to RSpec randomization). + connection.execute("GRANT ALL ON SCHEMA public TO #{ROLES[:db_manager]}") + connection.execute("GRANT ALL ON ALL TABLES IN SCHEMA public TO #{ROLES[:db_manager]}") + connection.execute("GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO #{ROLES[:db_manager]}") + # Cover tables/sequences created AFTER this provisioning runs. Without + # FOR ROLE, applies to objects created later by the current session user + # (typically 'postgres') in public. + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO #{ROLES[:db_manager]}" + ) + connection.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO #{ROLES[:db_manager]}" + ) + end + + def provision_mysql_roles!(connection) + connection.execute("CREATE USER IF NOT EXISTS '#{ROLES[:db_manager]}'@'%'") + connection.execute("CREATE USER IF NOT EXISTS '#{ROLES[:app_user]}'@'%'") + connection.execute("GRANT ALL PRIVILEGES ON *.* TO '#{ROLES[:db_manager]}'@'%' WITH GRANT OPTION") + # No wildcard grant for app_user — tests must depend entirely on + # Mysql2Adapter#grant_privileges (fired during adapter.create). + connection.execute('FLUSH PRIVILEGES') + end + + private_class_method :provision_pg_roles!, :provision_mysql_roles! +end + +# Wire up the :rbac tag to provision roles once per context. +if V4_INTEGRATION_AVAILABLE + RSpec.configure do |config| + config.before(:context, :rbac) do + V4IntegrationHelper.ensure_test_database! + V4IntegrationHelper.establish_default_connection! + + unless RbacHelper.provision_roles!(ActiveRecord::Base.connection) + skip 'RBAC test roles not available. See docs/designs/v4-phase5.2-rbac-integration-tests.md' + end + end + end +end diff --git a/spec/integration/v4/tenant_lifecycle_spec.rb b/spec/integration/v4/tenant_lifecycle_spec.rb new file mode 100644 index 00000000..15884b71 --- /dev/null +++ b/spec/integration/v4/tenant_lifecycle_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Tenant lifecycle integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_lifecycle') } + let(:created_tenants) { [] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { [] } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + end + + after do + V4IntegrationHelper.cleanup_tenants!(created_tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'creates a tenant and can switch to it' do + Apartment.adapter.create('new_tenant') + created_tenants << 'new_tenant' + + Apartment::Tenant.switch('new_tenant') do + pool = ActiveRecord::Base.connection_pool + expect(pool).to(be_a(ActiveRecord::ConnectionAdapters::ConnectionPool)) + pool.with_connection { |conn| conn.execute('SELECT 1') } + end + end + + it 'drops a tenant and removes its pool' do + Apartment.adapter.create('doomed') + + Apartment::Tenant.switch('doomed') do + ActiveRecord::Base.connection.execute('SELECT 1') + end + + role = ActiveRecord::Base.current_role + expect(Apartment.pool_manager.tracked?("doomed:#{role}")).to(be(true)) + + Apartment.adapter.drop('doomed') + + expect(Apartment.pool_manager.tracked?("doomed:#{role}")).to(be(false)) + end + + it 'double drop does not raise' do + Apartment.adapter.create('drop_twice') + Apartment::Tenant.switch('drop_twice') do + ActiveRecord::Base.connection.execute('SELECT 1') + end + + Apartment.adapter.drop('drop_twice') + # Second drop should not raise — adapters use IF EXISTS / rm_f + expect { Apartment.adapter.drop('drop_twice') }.not_to(raise_error) + end + + it 'creates the tenant storage artifact', if: V4IntegrationHelper.sqlite? do + Apartment.adapter.create('file_check') + created_tenants << 'file_check' + + Apartment::Tenant.switch('file_check') do + ActiveRecord::Base.connection.execute('SELECT 1') + end + + expected_path = File.join(tmp_dir, 'file_check.sqlite3') + expect(File.exist?(expected_path)).to(be(true)) + end + + it 'creates a schema', if: V4IntegrationHelper.postgresql? do + Apartment.adapter.create('lifecycle_schema') + created_tenants << 'lifecycle_schema' + + schemas = ActiveRecord::Base.connection.select_values( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'lifecycle_schema'" + ) + expect(schemas).to(include('lifecycle_schema')) + end + + it 'creates a database', if: V4IntegrationHelper.mysql? do + Apartment.adapter.create('lifecycle_db') + created_tenants << 'lifecycle_db' + + databases = ActiveRecord::Base.connection.select_values('SHOW DATABASES') + expect(databases).to(include('lifecycle_db')) + end +end diff --git a/spec/integration/v4/tenant_switching_spec.rb b/spec/integration/v4/tenant_switching_spec.rb new file mode 100644 index 00000000..3ab1e930 --- /dev/null +++ b/spec/integration/v4/tenant_switching_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'support' + +RSpec.describe('v4 Tenant switching integration', :integration, + skip: (V4_INTEGRATION_AVAILABLE ? false : 'requires ActiveRecord + database gem')) do + include V4IntegrationHelper + + let(:tmp_dir) { Dir.mktmpdir('apartment_switching') } + let(:tenants) { %w[tenant_a tenant_b] } + + before do + V4IntegrationHelper.ensure_test_database! unless V4IntegrationHelper.sqlite? + config = V4IntegrationHelper.establish_default_connection!(tmp_dir: tmp_dir) + V4IntegrationHelper.create_test_table! + + stub_const('Widget', Class.new(ActiveRecord::Base) do + self.table_name = 'widgets' + end) + + Apartment.configure do |c| + c.tenant_strategy = V4IntegrationHelper.tenant_strategy + c.tenants_provider = -> { tenants } + c.default_tenant = V4IntegrationHelper.default_tenant + c.check_pending_migrations = false + end + + Apartment.adapter = V4IntegrationHelper.build_adapter(config) + Apartment.activate! + + tenants.each do |tenant| + Apartment.adapter.create(tenant) + Apartment::Tenant.switch(tenant) do + V4IntegrationHelper.create_test_table!('widgets', connection: ActiveRecord::Base.connection) + end + end + end + + after do + V4IntegrationHelper.cleanup_tenants!(tenants, Apartment.adapter) + Apartment.clear_config + Apartment::Current.reset + if V4IntegrationHelper.sqlite? + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + FileUtils.rm_rf(tmp_dir) + end + end + + it 'isolates data between tenants' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'Alice Widget') + end + + Apartment::Tenant.switch('tenant_b') do + expect(Widget.count).to(eq(0)) + end + + Apartment::Tenant.switch('tenant_a') do + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('Alice Widget')) + end + end + + it 'restores tenant context on exception' do + expect do + Apartment::Tenant.switch('tenant_a') do + raise('boom') + end + end.to(raise_error('boom')) + + expect(Apartment::Tenant.current).to(eq(V4IntegrationHelper.default_tenant)) + end + + it 'supports nested switching' do + Apartment::Tenant.switch('tenant_a') do + Widget.create!(name: 'A') + Apartment::Tenant.switch('tenant_b') do + expect(Apartment::Tenant.current).to(eq('tenant_b')) + Widget.create!(name: 'B') + end + expect(Apartment::Tenant.current).to(eq('tenant_a')) + expect(Widget.count).to(eq(1)) + end + + Apartment::Tenant.switch('tenant_b') do + expect(Widget.count).to(eq(1)) + expect(Widget.first.name).to(eq('B')) + end + end +end diff --git a/spec/schemas/v1.rb b/spec/schemas/v1.rb deleted file mode 100644 index 052f5663..00000000 --- a/spec/schemas/v1.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). -# -# It's strongly recommended to check this file into your version control system. - -ActiveRecord::Schema.define(version: 0) do -end diff --git a/spec/schemas/v2.rb b/spec/schemas/v2.rb deleted file mode 100644 index baf7b998..00000000 --- a/spec/schemas/v2.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). -# -# It's strongly recommended to check this file into your version control system. - -ActiveRecord::Schema.define(version: 20110613152810) do - create_table 'companies', force: true do |t| - t.boolean 'dummy' - t.string 'database' - end - - create_table 'delayed_jobs', force: true 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', ['priority', 'run_at'], name: 'delayed_jobs_priority' - - create_table 'users', force: true do |t| - t.string 'name' - t.datetime 'birthdate' - t.string 'sex' - end -end diff --git a/spec/schemas/v3.rb b/spec/schemas/v3.rb deleted file mode 100644 index 2cd09a57..00000000 --- a/spec/schemas/v3.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). -# -# It's strongly recommended to check this file into your version control system. - -ActiveRecord::Schema.define(version: 20111202022214) do - create_table 'books', force: true do |t| - t.string 'name' - t.integer 'pages' - t.datetime 'published' - end - - create_table 'companies', force: true do |t| - t.boolean 'dummy' - t.string 'database' - end - - create_table 'delayed_jobs', force: true 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', ['priority', 'run_at'], name: 'delayed_jobs_priority' - - create_table 'users', force: true do |t| - t.string 'name' - t.datetime 'birthdate' - t.string 'sex' - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f5ba6bbe..9fecbb94 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,65 +1,33 @@ # frozen_string_literal: true -$LOAD_PATH.unshift(File.dirname(__FILE__)) - -# Configure Rails Environment -ENV['RAILS_ENV'] = 'test' - -require File.expand_path('dummy/config/environment.rb', __dir__) - -# Loading dummy applications affects table_name of each excluded models -# defined in `spec/dummy/config/initializers/apartment.rb`. -# To make them pristine, we need to execute below lines. -Apartment.excluded_models.each do |model| - klass = model.constantize - - Apartment.connection_class.remove_connection(klass) - klass.clear_all_connections! - klass.reset_table_name +if ENV['COVERAGE'] + require 'simplecov' + SimpleCov.start do + add_filter '/spec/' + add_filter '/gemfiles/' + add_group 'Adapters', 'lib/apartment/adapters' + add_group 'Patches', 'lib/apartment/patches' + add_group 'Config', 'lib/apartment/configs' + add_group 'Core', 'lib/apartment' + minimum_coverage 80 + end end -require 'rspec/rails' -require 'capybara/rspec' -require 'capybara/rails' +require 'bundler/setup' -# rubocop:disable Lint/ConstantDefinitionInBlock +# Load real ActiveRecord when available (appraisal gemfiles include it). +# This must happen before any spec file defines an AR stub. begin - require 'pry' - silence_warnings { IRB = Pry } + require('active_record') rescue LoadError - nil + # Not available — specs that need it will skip or use stubs. end -# rubocop:enable Lint/ConstantDefinitionInBlock -ActionMailer::Base.delivery_method = :test -ActionMailer::Base.perform_deliveries = true -ActionMailer::Base.default_url_options[:host] = 'test.com' - -Rails.backtrace_cleaner.remove_silencers! - -# Load support files -Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } +require 'apartment' RSpec.configure do |config| - config.include RSpec::Integration::CapybaraSessions, type: :request - config.include Apartment::Spec::Setup - - # Somewhat brutal hack so that rails 4 postgres extensions don't modify this file - config.after(:all) do - `git checkout -- spec/dummy/db/schema.rb` + config.after do + Apartment.clear_config + Apartment::Current.reset end - - # rspec-rails 3 will no longer automatically infer an example group's spec type - # from the file location. You can explicitly opt-in to the feature using this - # config option. - # To explicitly tag specs without using automatic inference, set the `:type` - # metadata manually: - # - # describe ThingsController, :type => :controller do - # # Equivalent to being in spec/controllers - # end - config.infer_spec_type_from_file_location! end - -# Load shared examples, must happen after configure for RSpec 3 -Dir["#{File.dirname(__FILE__)}/examples/**/*.rb"].each { |f| require f } diff --git a/spec/support/apartment_helpers.rb b/spec/support/apartment_helpers.rb deleted file mode 100644 index 0641b13f..00000000 --- a/spec/support/apartment_helpers.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module Apartment - module Test - # rubocop:disable Style/ModuleFunction - extend self - # rubocop:enable Style/ModuleFunction - - def reset - Apartment.excluded_models = nil - Apartment.use_schemas = nil - Apartment.seed_after_create = nil - Apartment.default_tenant = nil - end - - def next_db - @x ||= 0 - format('db%d', db_idx: @x += 1) - end - - def drop_schema(schema) - ActiveRecord::Base.connection.execute("DROP SCHEMA IF EXISTS #{schema} CASCADE") - rescue StandardError => _e - true - end - - # Use this if you don't want to import schema.rb etc... but need the postgres schema to exist - # basically for speed purposes - def create_schema(schema) - ActiveRecord::Base.connection.execute("CREATE SCHEMA #{schema}") - end - - def load_schema(version = 3) - file = File.expand_path("../../schemas/v#{version}.rb", __FILE__) - - silence_warnings { load(file) } - end - - def migrate - ActiveRecord::Migrator.migrate(Rails.root + ActiveRecord::Migrator.migrations_path) - end - - def rollback - ActiveRecord::Migrator.rollback(Rails.root + ActiveRecord::Migrator.migrations_path) - end - end -end diff --git a/spec/support/capybara_sessions.rb b/spec/support/capybara_sessions.rb deleted file mode 100644 index def4985c..00000000 --- a/spec/support/capybara_sessions.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module RSpec - module Integration - module CapybaraSessions - def in_new_session(&_block) - yield new_session - end - - def new_session - Capybara::Session.new(Capybara.current_driver, Capybara.app) - end - end - end -end diff --git a/spec/support/config.rb b/spec/support/config.rb deleted file mode 100644 index ce91ad1a..00000000 --- a/spec/support/config.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require 'yaml' - -module Apartment - module Test - def self.config - # rubocop:disable Security/YAMLLoad - @config ||= YAML.load(ERB.new(IO.read('spec/config/database.yml')).result) - # rubocop:enable Security/YAMLLoad - end - end -end diff --git a/spec/support/contexts.rb b/spec/support/contexts.rb deleted file mode 100644 index 6581f8b0..00000000 --- a/spec/support/contexts.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -# Some shared contexts for specs - -shared_context 'with default schema', default_tenant: true do - let(:default_tenant) { Apartment::Test.next_db } - - before do - Apartment::Test.create_schema(default_tenant) - Apartment.default_tenant = default_tenant - end - - after do - # resetting default_tenant so we can drop and any further resets won't try to access droppped schema - Apartment.default_tenant = nil - Apartment::Test.drop_schema(default_tenant) - end -end - -# Some default setup for elevator specs -shared_context 'elevators', elevator: true do - let(:company1) { mock_model(Company, database: db1).as_null_object } - let(:company2) { mock_model(Company, database: db2).as_null_object } - - let(:api) { Apartment::Tenant } - - before do - Apartment.reset # reset all config - Apartment.seed_after_create = false - Apartment.use_schemas = true - api.reload!(config) - api.create(db1) - api.create(db2) - end - - after do - api.drop(db1) - api.drop(db2) - end -end - -shared_context 'persistent_schemas', persistent_schemas: true do - let(:persistent_schemas) { %w[hstore postgis] } - - before do - persistent_schemas.map { |schema| subject.create(schema) } - Apartment.persistent_schemas = persistent_schemas - end - - after do - Apartment.persistent_schemas = [] - persistent_schemas.map { |schema| subject.drop(schema) } - end -end diff --git a/spec/support/requirements.rb b/spec/support/requirements.rb deleted file mode 100644 index 0a5fdf7d..00000000 --- a/spec/support/requirements.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module Apartment - module Spec - # - # Define the interface methods required to - # use an adapter shared example - # - # - module AdapterRequirements - extend ActiveSupport::Concern - - included do - before do - subject.create(db1) - subject.create(db2) - end - - after do - # Reset before dropping (can't drop a db you're connected to) - subject.reset - - # sometimes we manually drop these schemas in testing, don't care if - # we can't drop, hence rescue - begin - subject.drop(db1) - rescue StandardError => _e - true - end - - begin - subject.drop(db2) - rescue StandardError => _e - true - end - end - end - - %w[subject tenant_names default_tenant].each do |method| - next if defined?(method) - - define_method method do - raise "You must define a `#{method}` method in your host group" - end - end - end - end -end diff --git a/spec/support/setup.rb b/spec/support/setup.rb deleted file mode 100644 index 109cbaef..00000000 --- a/spec/support/setup.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module Apartment - module Spec - module Setup - # rubocop:disable Metrics/AbcSize - def self.included(base) - base.instance_eval do - let(:db1) { Apartment::Test.next_db } - let(:db2) { Apartment::Test.next_db } - let(:connection) { ActiveRecord::Base.connection } - - # 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| - def config - db = RSpec.current_example.metadata.fetch(:database, :postgresql) - - Apartment::Test.config['connections'][db.to_s].symbolize_keys - end - - # before - Apartment::Tenant.reload!(config) - ActiveRecord::Base.establish_connection config - - example.run - - # after - Rails.configuration.database_configuration = {} - ActiveRecord::Base.clear_all_connections! - - Apartment.excluded_models.each do |model| - klass = model.constantize - - Apartment.connection_class.remove_connection(klass) - klass.clear_all_connections! - klass.reset_table_name - end - Apartment.reset - Apartment::Tenant.reload! - end - end - end - # rubocop:enable Metrics/AbcSize - end - end -end diff --git a/spec/tasks/apartment_rake_spec.rb b/spec/tasks/apartment_rake_spec.rb deleted file mode 100644 index f71727a9..00000000 --- a/spec/tasks/apartment_rake_spec.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'rake' -require 'apartment/migrator' -require 'apartment/tenant' - -describe 'apartment rake tasks' do - before do - @rake = Rake::Application.new - Rake.application = @rake - load 'tasks/apartment.rake' - # stub out rails tasks - Rake::Task.define_task('db:migrate') - Rake::Task.define_task('db:seed') - Rake::Task.define_task('db:rollback') - Rake::Task.define_task('db:migrate:up') - Rake::Task.define_task('db:migrate:down') - Rake::Task.define_task('db:migrate:redo') - end - - after do - Rake.application = nil - ENV['VERSION'] = nil # linux users reported env variable carrying on between tests - end - - after(:all) do - Apartment::Test.load_schema - end - - let(:version) { '1234' } - - context 'database migration' do - let(:tenant_names) { 3.times.map { Apartment::Test.next_db } } - let(:tenant_count) { tenant_names.length } - - before do - 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 - end - - it 'should migrate public and all multi-tenant dbs' do - expect(Apartment::Migrator).to receive(:migrate).exactly(tenant_count).times - @rake['apartment:migrate'].invoke - end - end - - describe 'apartment:migrate:up' do - context 'without a version' do - before do - ENV['VERSION'] = nil - end - - it 'requires a version to migrate to' do - expect do - @rake['apartment:migrate:up'].invoke - end.to raise_error('VERSION is required') - end - end - - context 'with version' do - before do - ENV['VERSION'] = version - 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 - @rake['apartment:migrate:up'].invoke - end - end - end - - describe 'apartment:migrate:down' do - context 'without a version' do - before do - ENV['VERSION'] = nil - end - - it 'requires a version to migrate to' do - expect do - @rake['apartment:migrate:down'].invoke - end.to raise_error('VERSION is required') - end - end - - context 'with version' do - before do - ENV['VERSION'] = version - 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 - @rake['apartment:migrate:down'].invoke - end - end - end - - describe 'apartment:rollback' do - let(:step) { '3' } - - it 'should rollback 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 - 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 - @rake['apartment:drop'].invoke - end - end - end -end diff --git a/spec/tenant_spec.rb b/spec/tenant_spec.rb deleted file mode 100644 index e3aacf04..00000000 --- a/spec/tenant_spec.rb +++ /dev/null @@ -1,194 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Apartment::Tenant do - context 'using mysql', database: :mysql do - before { subject.reload!(config) } - - describe '#adapter' do - it 'should load mysql adapter' do - subject.adapter - expect(Apartment::Adapters::Mysql2Adapter).to be_a(Class) - end - end - - # TODO: this doesn't belong here, but there aren't integration tests currently for mysql - # where to put??? - describe 'exception recovery', type: :request do - before do - subject.create db1 - end - after { subject.drop db1 } - - # it "should recover from incorrect database" do - # session = Capybara::Session.new(:rack_test, Capybara.app) - # session.visit("http://#{db1}.com") - # expect { - # session.visit("http://this-database-should-not-exist.com") - # }.to raise_error - # session.visit("http://#{db1}.com") - # end - end - - # TODO: re-organize these tests - context 'with prefix and schemas' do - describe '#create' do - before do - Apartment.configure do |config| - config.prepend_environment = true - config.use_schemas = true - end - - subject.reload!(config) - end - - after do - begin - subject.drop 'db_with_prefix' - rescue StandardError => _e - nil - end - end - - it 'should create a new database' do - subject.create 'db_with_prefix' - end - end - end - end - - context 'using postgresql', database: :postgresql do - before do - Apartment.use_schemas = true - subject.reload!(config) - end - - describe '#adapter' do - it 'should load postgresql adapter' do - expect(subject.adapter).to be_a(Apartment::Adapters::PostgresqlSchemaAdapter) - end - - it 'raises exception with invalid adapter specified' do - subject.reload!(config.merge(adapter: 'unknown')) - - expect do - Apartment::Tenant.adapter - end.to raise_error(RuntimeError) - end - - context 'threadsafety' do - before { subject.create 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.join - expect(subject.current).to eq(db1) - end - end - end - - # TODO: above spec are also with use_schemas=true - context 'with schemas' do - before do - Apartment.configure do |config| - config.excluded_models = [] - config.use_schemas = true - config.seed_after_create = true - end - subject.create db1 - end - - after { subject.drop db1 } - - describe '#create' do - it 'should seed data' do - subject.switch! db1 - expect(User.count).to be > 0 - end - end - - describe '#switch!' do - let(:x) { rand(3) } - - context 'creating models' do - before { subject.create db2 } - after { subject.drop db2 } - - it 'should create a model instance in the current schema' do - subject.switch! db2 - db2_count = User.count + x.times { User.create } - - subject.switch! db1 - db_count = User.count + x.times { User.create } - - subject.switch! db2 - expect(User.count).to eq(db2_count) - - subject.switch! db1 - expect(User.count).to eq(db_count) - end - end - - context 'with excluded models' do - before do - Apartment.configure do |config| - config.excluded_models = ['Company'] - end - subject.init - end - - after do - # 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 'should create excluded models in public schema' do - subject.reset # ensure we're on public schema - count = Company.count + x.times { Company.create } - - subject.switch! db1 - x.times { Company.create } - expect(Company.count).to eq(count + x) - subject.reset - expect(Company.count).to eq(count + x) - end - end - end - end - - context 'seed paths' do - before do - Apartment.configure do |config| - config.excluded_models = [] - config.use_schemas = true - config.seed_after_create = true - end - end - - 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') - end - - it 'should seed 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') - end - end - end -end diff --git a/spec/unit/adapters/abstract_adapter_spec.rb b/spec/unit/adapters/abstract_adapter_spec.rb new file mode 100644 index 00000000..3f29e104 --- /dev/null +++ b/spec/unit/adapters/abstract_adapter_spec.rb @@ -0,0 +1,570 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/adapters/abstract_adapter' +require_relative '../../../lib/apartment/concerns/model' + +# Concrete test subclass that implements protected abstract methods. +class TestAdapter < Apartment::Adapters::AbstractAdapter + attr_reader :created_tenants, :dropped_tenants + + def initialize(config) + super + @created_tenants = [] + @dropped_tenants = [] + end + + def resolve_connection_config(tenant, base_config: nil) + config = base_config || { 'adapter' => 'postgresql', 'database' => tenant } + config.merge('database' => tenant) + end + + protected + + def create_tenant(tenant) + @created_tenants << tenant + end + + def drop_tenant(tenant) + @dropped_tenants << tenant + end +end + +# Minimal Rails stub for environmentify tests. +unless defined?(Rails) + module Rails + def self.env + 'test' + end + + def self.root + Pathname.new('/rails/app') + end + end +end + +# Minimal ActiveRecord stub for migrate tests. +unless defined?(ActiveRecord::Base) + module ActiveRecord + class Base + def self.connection_pool + raise('stub: override with allow in tests') + end + end + end +end + +RSpec.describe(Apartment::Adapters::AbstractAdapter) do + let(:connection_config) { { adapter: 'postgresql', host: 'localhost' } } + let(:adapter) { TestAdapter.new(connection_config) } + + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'public' + c.schema_load_strategy = nil # disable by default in tests (explicit in schema loading tests) + end + end + + # Helper: reconfigure Apartment with overrides (Config is frozen after configure, + # so we must reconfigure rather than stub individual accessors). + def reconfigure(**overrides) + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'public' + overrides.each { |key, val| c.send(:"#{key}=", val) } + end + end + + describe '#initialize' do + it 'stores the connection_config' do + expect(adapter.connection_config).to(eq(connection_config)) + end + end + + describe '#validated_connection_config' do + it 'returns the resolved config for valid tenant names' do + result = adapter.validated_connection_config('acme') + expect(result).to(eq('adapter' => 'postgresql', 'database' => 'acme', 'host' => 'localhost')) + end + + it 'raises ConfigurationError for invalid tenant names' do + expect { adapter.validated_connection_config("bad\x00name") } + .to(raise_error(Apartment::ConfigurationError, /NUL byte/)) + end + + it 'raises ConfigurationError for empty tenant names' do + expect { adapter.validated_connection_config('') } + .to(raise_error(Apartment::ConfigurationError, /cannot be empty/)) + end + + it 'falls back to base_config when base_config_override is nil' do + result = adapter.validated_connection_config('acme', base_config_override: nil) + expect(result).to(eq('adapter' => 'postgresql', 'database' => 'acme', 'host' => 'localhost')) + end + end + + describe '#resolve_connection_config' do + it 'raises NotImplementedError on the abstract class' do + abstract = described_class.new(connection_config) + expect { abstract.resolve_connection_config('t1') }.to(raise_error(NotImplementedError)) + end + + it 'returns a config hash in the concrete subclass' do + expect(adapter.resolve_connection_config('t1')).to(eq('adapter' => 'postgresql', 'database' => 't1')) + end + end + + describe '#create' do + it 'delegates to create_tenant' do + allow(Apartment::Instrumentation).to(receive(:instrument)) + adapter.create('acme') + expect(adapter.created_tenants).to(eq(['acme'])) + end + + it 'instruments the create event' do + expect(Apartment::Instrumentation).to(receive(:instrument).with(:create, tenant: 'acme')) + adapter.create('acme') + end + + it 'raises ConfigurationError for invalid tenant names before creating' do + allow(Apartment::Instrumentation).to(receive(:instrument)) + expect { adapter.create("bad\x00name") } + .to(raise_error(Apartment::ConfigurationError, /NUL byte/)) + # Should not have called create_tenant + expect(adapter.created_tenants).to(be_empty) + end + + it 'validates the environmentified name, not just the raw name' do + # Raw name is 59 chars (valid for PG 63 limit), but "test_" prefix makes 64 (exceeds 63) + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('test')) + allow(Apartment::Instrumentation).to(receive(:instrument)) + + tenant = 'a' * 59 + expect { adapter.create(tenant) } + .to(raise_error(Apartment::ConfigurationError, /too long.*64.*max 63/)) + expect(adapter.created_tenants).to(be_empty) + end + + it 'runs :create callbacks around the operation' do + callback_log = [] + + TestAdapter.set_callback(:create, :before) { callback_log << :before } + TestAdapter.set_callback(:create, :after) { callback_log << :after } + + allow(Apartment::Instrumentation).to(receive(:instrument)) + adapter.create('acme') + + expect(callback_log).to(eq(%i[before after])) + ensure + TestAdapter.reset_callbacks(:create) + end + end + + describe '#drop' do + let(:pool_manager) { Apartment.pool_manager } + + it 'delegates to drop_tenant' do + allow(Apartment::Instrumentation).to(receive(:instrument)) + adapter.drop('acme') + expect(adapter.dropped_tenants).to(eq(['acme'])) + end + + it 'removes all role-variant pools via remove_tenant on PoolManager' do + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(Apartment).to(receive(:deregister_shard)) + expect(pool_manager).to(receive(:remove_tenant).with('acme').and_return([])) + adapter.drop('acme') + end + + it 'disconnects each pool returned by remove_tenant' do + mock_pool = double('Pool', disconnect!: true) + allow(pool_manager).to(receive(:remove_tenant).and_return([['acme:primary', mock_pool]])) + allow(Apartment).to(receive(:deregister_shard)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + + expect(mock_pool).to(receive(:disconnect!)) + adapter.drop('acme') + end + + it 'does not call disconnect! if pool does not respond to it' do + mock_pool = double('Pool') + allow(pool_manager).to(receive(:remove_tenant).and_return([['acme:primary', mock_pool]])) + allow(Apartment).to(receive(:deregister_shard)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + + # Should not raise + adapter.drop('acme') + end + + it 'instruments the drop event' do + allow(pool_manager).to(receive(:remove_tenant).and_return([])) + allow(Apartment).to(receive(:deregister_shard)) + expect(Apartment::Instrumentation).to(receive(:instrument).with(:drop, tenant: 'acme')) + adapter.drop('acme') + end + + it 'deregisters each pool_key from AR ConnectionHandler' do + mock_pool = double('Pool', disconnect!: true) + removed = [['acme:primary', mock_pool], ['acme:replica', mock_pool]] + allow(pool_manager).to(receive(:remove_tenant).and_return(removed)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + + expect(Apartment).to(receive(:deregister_shard).with('acme:primary')) + expect(Apartment).to(receive(:deregister_shard).with('acme:replica')) + adapter.drop('acme') + end + + it 'still deregisters shard and instruments when disconnect! raises' do + mock_pool = double('Pool') + allow(mock_pool).to(receive(:respond_to?).with(:disconnect!).and_return(true)) + allow(mock_pool).to(receive(:disconnect!).and_raise(RuntimeError, 'disconnect boom')) + allow(pool_manager).to(receive(:remove_tenant).and_return([['acme:primary', mock_pool]])) + + expect(Apartment).to(receive(:deregister_shard).with('acme:primary')) + expect(Apartment::Instrumentation).to(receive(:instrument).with(:drop, tenant: 'acme')) + + adapter.drop('acme') + end + + it 'handles nil pool_manager gracefully' do + allow(Apartment).to(receive(:pool_manager).and_return(nil)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + + # Should not raise even without a pool_manager + expect { adapter.drop('acme') }.not_to(raise_error) + end + end + + describe '#migrate' do + it 'sets Current.tenant during the migration block' do + tenant_during_migrate = nil + migration_context = double('MigrationContext') + connection_pool = double('ConnectionPool', migration_context: migration_context) + + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(connection_pool)) + allow(migration_context).to(receive(:migrate) { tenant_during_migrate = Apartment::Current.tenant }) + + adapter.migrate('acme') + expect(tenant_during_migrate).to(eq('acme')) + end + + it 'switches tenant and runs migrations' do + migration_context = double('MigrationContext') + connection_pool = double('ConnectionPool', migration_context: migration_context) + + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(connection_pool)) + expect(migration_context).to(receive(:migrate).with(nil)) + + adapter.migrate('acme') + end + + it 'passes version to migrate' do + migration_context = double('MigrationContext') + connection_pool = double('ConnectionPool', migration_context: migration_context) + + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(connection_pool)) + expect(migration_context).to(receive(:migrate).with(20_260_101_000_000)) + + adapter.migrate('acme', 20_260_101_000_000) + end + + it 'restores tenant context after migration' do + migration_context = double('MigrationContext', migrate: true) + connection_pool = double('ConnectionPool', migration_context: migration_context) + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(connection_pool)) + + Apartment::Current.tenant = 'original' + adapter.migrate('acme') + expect(Apartment::Current.tenant).to(eq('original')) + end + end + + describe '#seed' do + it 'sets Current.tenant during the seed block' do + tenant_during_seed = nil + reconfigure(seed_data_file: '/tmp/seeds.rb') + allow(File).to(receive(:exist?).with('/tmp/seeds.rb').and_return(true)) + allow(adapter).to(receive(:load) { tenant_during_seed = Apartment::Current.tenant }) + + adapter.seed('acme') + expect(tenant_during_seed).to(eq('acme')) + end + + it 'switches tenant and loads the seed file' do + reconfigure(seed_data_file: '/tmp/seeds.rb') + allow(File).to(receive(:exist?).with('/tmp/seeds.rb').and_return(true)) + expect(adapter).to(receive(:load).with('/tmp/seeds.rb')) + + adapter.seed('acme') + end + + it 'does nothing when seed_data_file is nil' do + # Default config has seed_data_file = nil + expect(adapter).not_to(receive(:load)) + + adapter.seed('acme') + end + + it 'raises ConfigurationError when seed file does not exist' do + reconfigure(seed_data_file: '/tmp/missing.rb') + allow(File).to(receive(:exist?).with('/tmp/missing.rb').and_return(false)) + + expect { adapter.seed('acme') }.to(raise_error( + Apartment::ConfigurationError, + "Seed file '/tmp/missing.rb' does not exist" + )) + end + end + + describe '#process_pinned_models' do + it 'establishes connections for each pinned model' do + model_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedSetting', model_class) + allow(model_class).to(receive(:table_name).and_return('pinned_settings')) + allow(model_class).to(receive(:table_name=)) + + PinnedSetting.pin_tenant + + # Pinned models use base_config (the adapter's raw connection config), + # not resolve_connection_config(default_tenant) — avoids database-per-tenant + # strategies setting database key to the tenant name instead of the real DB. + expected_config = { 'adapter' => 'postgresql', 'host' => 'localhost' } + expect(model_class).to(receive(:establish_connection)) do |arg| + expect(arg).to(eq(expected_config)) + end + + adapter.process_pinned_models + end + + it 'skips models already processed (idempotent)' do + model_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('AlreadyPinned', model_class) + allow(model_class).to(receive(:table_name).and_return('already_pinned')) + allow(model_class).to(receive(:table_name=)) + + AlreadyPinned.pin_tenant + + # First call processes the model + allow(model_class).to(receive(:establish_connection)) + adapter.process_pinned_models + + # Second call skips — @apartment_connection_established is set + expect(model_class).not_to(receive(:establish_connection)) + adapter.process_pinned_models + end + + it 'prefixes table name with default schema for schema strategy' do + model_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('SchemaPinned', model_class) + allow(model_class).to(receive(:establish_connection)) + allow(model_class).to(receive(:table_name).and_return('schema_pinned')) + + SchemaPinned.pin_tenant + + expect(model_class).to(receive(:table_name=).with('public.schema_pinned')) + adapter.process_pinned_models + end + + it 'does not prefix table name for database_name strategy' do + model_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('DbPinned', model_class) + allow(model_class).to(receive(:establish_connection)) + allow(model_class).to(receive(:table_name).and_return('db_pinned')) + + DbPinned.pin_tenant + + reconfigure(tenant_strategy: :database_name) + + expect(model_class).not_to(receive(:table_name=)) + adapter.process_pinned_models + end + + it 'does nothing when no models are pinned' do + expect { adapter.process_pinned_models }.not_to(raise_error) + end + end + + describe '#process_excluded_models (deprecated)' do + it 'emits a deprecation warning' do + expect { adapter.process_excluded_models } + .to(output(/DEPRECATION.*process_excluded_models/).to_stderr) + end + + it 'delegates to process_pinned_models' do + expect(adapter).to(receive(:process_pinned_models)) + adapter.process_excluded_models + end + end + + describe '#environmentify' do + it 'prepends the environment when strategy is :prepend' do + reconfigure(environmentify_strategy: :prepend) + expect(adapter.environmentify('acme')).to(eq('test_acme')) + end + + it 'appends the environment when strategy is :append' do + reconfigure(environmentify_strategy: :append) + expect(adapter.environmentify('acme')).to(eq('acme_test')) + end + + it 'returns tenant as string when strategy is nil' do + # Default config has environmentify_strategy = nil + expect(adapter.environmentify('acme')).to(eq('acme')) + end + + it 'converts symbols to string when strategy is nil' do + expect(adapter.environmentify(:acme)).to(eq('acme')) + end + + it 'calls the strategy when it is callable' do + reconfigure(environmentify_strategy: ->(tenant) { "custom_#{tenant}" }) + expect(adapter.environmentify('acme')).to(eq('custom_acme')) + end + + it 'raises ConfigurationError when Rails is not defined and strategy needs it' do + reconfigure(environmentify_strategy: :prepend) + # Simulate Rails being undefined by making rails_env raise + allow(adapter).to(receive(:rails_env).and_raise( + Apartment::ConfigurationError, + 'environmentify_strategy :prepend/:append requires Rails to be defined' + )) + expect { adapter.environmentify('acme') }.to(raise_error(Apartment::ConfigurationError, /requires Rails/)) + end + end + + describe '#default_tenant' do + it 'delegates to Apartment.config.default_tenant' do + expect(adapter.default_tenant).to(eq('public')) + end + end + + describe '#grant_tenant_privileges (private)' do + let(:connection) { double('Connection') } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + end + + context 'when app_role is a string' do + before { reconfigure(app_role: 'app_user') } + + it 'calls grant_privileges with tenant, connection, and role_name' do + expect(adapter).to(receive(:grant_privileges).with('acme', connection, 'app_user')) + adapter.create('acme') + end + end + + context 'when app_role is callable' do + it 'invokes the callable with (tenant, connection)' do + called_with = nil + reconfigure(app_role: ->(tenant, conn) { called_with = [tenant, conn] }) + + adapter.create('acme') + + expect(called_with).to(eq(['acme', connection])) + end + end + + context 'when app_role is nil' do + it 'does not call grant_privileges and does not raise' do + # Default config has app_role = nil + expect(adapter).not_to(receive(:grant_privileges)) + expect { adapter.create('acme') }.not_to(raise_error) + end + end + + context 'ordering: grants run after create_tenant, before import_schema' do + it 'calls create_tenant then grant_tenant_privileges then import_schema' do + reconfigure(app_role: 'app_user', schema_load_strategy: :schema_rb) + call_order = [] + + allow(adapter).to(receive(:create_tenant).with('acme') { call_order << :create_tenant }) + allow(adapter).to(receive(:grant_privileges) { call_order << :grant_privileges }) + allow(adapter).to(receive(:import_schema).with('acme') { call_order << :import_schema }) + + adapter.create('acme') + + expect(call_order).to(eq(%i[create_tenant grant_privileges import_schema])) + end + end + end + + describe '#create with schema loading' do + it 'calls import_schema when schema_load_strategy is set' do + reconfigure(schema_load_strategy: :schema_rb) + allow(Apartment::Instrumentation).to(receive(:instrument)) + expect(adapter).to(receive(:import_schema).with('acme')) + adapter.create('acme') + end + + it 'does not call import_schema when strategy is nil' do + # Default in tests is nil + allow(Apartment::Instrumentation).to(receive(:instrument)) + expect(adapter).not_to(receive(:import_schema)) + adapter.create('acme') + end + + it 'calls seed after schema when seed_after_create is true' do + reconfigure(schema_load_strategy: :schema_rb, seed_after_create: true, seed_data_file: '/tmp/seeds.rb') + allow(Apartment::Instrumentation).to(receive(:instrument)) + call_order = [] + allow(adapter).to(receive(:import_schema) { call_order << :schema }) + allow(File).to(receive(:exist?).and_return(true)) + allow(adapter).to(receive(:load) { call_order << :seed }) + adapter.create('acme') + expect(call_order).to(eq(%i[schema seed])) + end + end + + describe '#resolve_schema_file (private)' do + it 'returns custom schema_file when configured' do + reconfigure(schema_file: '/custom/schema.rb') + expect(adapter.send(:resolve_schema_file)).to(eq('/custom/schema.rb')) + end + + it 'returns db/schema.rb path when Rails is defined' do + result = adapter.send(:resolve_schema_file) + expect(result).to(include('schema.rb')) + end + end + + describe '#import_schema (private)' do + it 'calls load with resolved schema file for :schema_rb' do + reconfigure(schema_load_strategy: :schema_rb, schema_file: '/tmp/test_schema.rb') + expect(adapter).to(receive(:load).with('/tmp/test_schema.rb')) + adapter.send(:import_schema, 'acme') + end + + it 'wraps errors in SchemaLoadError' do + reconfigure(schema_load_strategy: :schema_rb, schema_file: '/tmp/bad.rb') + allow(adapter).to(receive(:load).and_raise(RuntimeError, 'syntax error')) + expect { adapter.send(:import_schema, 'acme') } + .to(raise_error(Apartment::SchemaLoadError, /syntax error/)) + end + end + + describe 'protected abstract methods' do + it 'create_tenant raises NotImplementedError on the abstract class' do + abstract = described_class.new(connection_config) + expect { abstract.send(:create_tenant, 't1') }.to(raise_error(NotImplementedError)) + end + + it 'drop_tenant raises NotImplementedError on the abstract class' do + abstract = described_class.new(connection_config) + expect { abstract.send(:drop_tenant, 't1') }.to(raise_error(NotImplementedError)) + end + end +end diff --git a/spec/unit/adapters/mysql2_adapter_spec.rb b/spec/unit/adapters/mysql2_adapter_spec.rb new file mode 100644 index 00000000..5376dc27 --- /dev/null +++ b/spec/unit/adapters/mysql2_adapter_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/adapters/mysql2_adapter' +require_relative '../../../lib/apartment/adapters/trilogy_adapter' + +# Minimal ActiveRecord stubs for SQL execution tests. +unless defined?(ActiveRecord::Base) + module ActiveRecord + class Base + def self.connection + raise('stub: override with allow in tests') + end + end + end +end + +# Minimal Rails stub for environmentify tests. +unless defined?(Rails) + module Rails + def self.env + 'test' + end + end +end + +# Shared examples for MySQL-family adapters (MySQL2 and Trilogy share identical behavior). +RSpec.shared_examples('a MySQL adapter') do + let(:connection_config) { { adapter: adapter_name, host: 'localhost', database: 'myapp' } } + let(:adapter) { described_class.new(connection_config) } + + before do + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'myapp' + c.schema_load_strategy = nil + end + end + + # Helper: reconfigure Apartment with overrides (Config is frozen after configure, + # so we must reconfigure rather than stub individual accessors). + def reconfigure(**overrides) + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'myapp' + c.schema_load_strategy = nil + overrides.each { |key, val| c.send(:"#{key}=", val) } + end + end + + describe '#resolve_connection_config' do + it 'returns config with database key set to tenant name (nil strategy = plain name)' do + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('acme')) + end + + it 'stringifies all config keys' do + result = adapter.resolve_connection_config('acme') + + expect(result.keys).to(all(be_a(String))) + expect(result['adapter']).to(eq(adapter_name)) + expect(result['host']).to(eq('localhost')) + end + + it 'uses environmentify with :prepend strategy' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('staging')) + + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('staging_acme')) + end + + it 'uses environmentify with :append strategy' do + reconfigure(environmentify_strategy: :append) + allow(Rails).to(receive(:env).and_return('staging')) + + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('acme_staging')) + end + + it 'preserves all original connection config keys' do + config = { adapter: adapter_name, host: 'db.example.com', database: 'app', port: 3306, pool: 10 } + local_adapter = described_class.new(config) + + result = local_adapter.resolve_connection_config('tenant1') + + expect(result['port']).to(eq(3306)) + expect(result['pool']).to(eq(10)) + expect(result['host']).to(eq('db.example.com')) + end + + it 'does not mutate the original connection_config' do + adapter.resolve_connection_config('acme') + + expect(adapter.connection_config).to(eq(connection_config)) + expect(adapter.connection_config[:database]).to(eq('myapp')) + end + end + + describe '#create (via create_tenant)' do + let(:connection) { double('Connection') } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + end + + it 'executes CREATE DATABASE with quoted environmentified name' do + allow(connection).to(receive(:quote_table_name).with('acme').and_return('`acme`')) + expect(connection).to(receive(:execute).with('CREATE DATABASE IF NOT EXISTS `acme`')) + + adapter.create('acme') + end + + it 'quotes tenant names that need escaping' do + allow(connection).to(receive(:quote_table_name).with('my-tenant').and_return('`my-tenant`')) + expect(connection).to(receive(:execute).with('CREATE DATABASE IF NOT EXISTS `my-tenant`')) + + adapter.create('my-tenant') + end + + it 'uses environmentified name for CREATE DATABASE' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('test')) + allow(connection).to(receive(:quote_table_name).with('test_acme').and_return('`test_acme`')) + expect(connection).to(receive(:execute).with('CREATE DATABASE IF NOT EXISTS `test_acme`')) + + adapter.create('acme') + end + + it 'validates the environmentified name against MySQL length limit' do + # Raw name is 60 chars (valid for MySQL 64 limit), but "test_" prefix makes 65 (exceeds 64) + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('test')) + + tenant = 'a' * 60 + expect { adapter.create(tenant) } + .to(raise_error(Apartment::ConfigurationError, /too long.*65.*max 64/)) + end + end + + describe '#grant_privileges (private)' do + let(:connection) { double('Connection') } + + before do + allow(connection).to(receive(:quote).with('app_user').and_return("'app_user'")) + allow(connection).to(receive(:quote_table_name)) { |name| "`#{name}`" } + end + + it 'executes exactly 1 SQL statement' do + expect(connection).to(receive(:execute).once) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'quotes the database name using quote_table_name' do + expect(connection).to(receive(:quote_table_name).with('acme').and_return('`acme`')) + allow(connection).to(receive(:execute)) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'environmentifies the database name in the GRANT' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('staging')) + allow(connection).to(receive(:quote_table_name).with('staging_acme').and_return('`staging_acme`')) + + expect(connection).to(receive(:execute) + .with("GRANT SELECT, INSERT, UPDATE, DELETE ON `staging_acme`.* TO 'app_user'@'%'")) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + end + + describe '#drop (via drop_tenant)' do + let(:connection) { double('Connection') } + let(:pool_manager) { Apartment.pool_manager } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(pool_manager).to(receive(:remove_tenant).and_return([])) + allow(Apartment).to(receive(:deregister_shard)) + end + + it 'executes DROP DATABASE IF EXISTS with quoted environmentified name' do + allow(connection).to(receive(:quote_table_name).with('acme').and_return('`acme`')) + expect(connection).to(receive(:execute).with('DROP DATABASE IF EXISTS `acme`')) + + adapter.drop('acme') + end + + it 'quotes tenant names that need escaping' do + allow(connection).to(receive(:quote_table_name).with('my-tenant').and_return('`my-tenant`')) + expect(connection).to(receive(:execute).with('DROP DATABASE IF EXISTS `my-tenant`')) + + adapter.drop('my-tenant') + end + + it 'uses environmentified name for DROP DATABASE' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('test')) + allow(connection).to(receive(:quote_table_name).with('test_acme').and_return('`test_acme`')) + expect(connection).to(receive(:execute).with('DROP DATABASE IF EXISTS `test_acme`')) + + adapter.drop('acme') + end + end +end + +RSpec.describe(Apartment::Adapters::Mysql2Adapter) do + describe 'inheritance' do + it 'is a subclass of AbstractAdapter' do + expect(described_class).to(be < Apartment::Adapters::AbstractAdapter) + end + end + + it_behaves_like 'a MySQL adapter' do + let(:adapter_name) { 'mysql2' } + end +end + +RSpec.describe(Apartment::Adapters::TrilogyAdapter) do + describe 'inheritance' do + it 'is a subclass of Mysql2Adapter' do + expect(described_class).to(be < Apartment::Adapters::Mysql2Adapter) + end + + it 'is a subclass of AbstractAdapter' do + expect(described_class).to(be < Apartment::Adapters::AbstractAdapter) + end + end + + it_behaves_like 'a MySQL adapter' do + let(:adapter_name) { 'trilogy' } + end +end diff --git a/spec/unit/adapters/postgresql_database_adapter_spec.rb b/spec/unit/adapters/postgresql_database_adapter_spec.rb new file mode 100644 index 00000000..4c927039 --- /dev/null +++ b/spec/unit/adapters/postgresql_database_adapter_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/adapters/postgresql_database_adapter' + +# Minimal ActiveRecord stubs for SQL execution tests. +unless defined?(ActiveRecord::Base) + module ActiveRecord + class Base + def self.connection + raise('stub: override with allow in tests') + end + end + end +end + +# Minimal Rails stub for environmentify tests. +unless defined?(Rails) + module Rails + def self.env + 'test' + end + end +end + +RSpec.describe(Apartment::Adapters::PostgresqlDatabaseAdapter) do + let(:connection_config) { { adapter: 'postgresql', host: 'localhost', database: 'myapp' } } + let(:adapter) { described_class.new(connection_config) } + + before do + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'myapp' + c.schema_load_strategy = nil + end + end + + # Helper: reconfigure Apartment with overrides (Config is frozen after configure, + # so we must reconfigure rather than stub individual accessors). + def reconfigure(**overrides) + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'myapp' + c.schema_load_strategy = nil + overrides.each { |key, val| c.send(:"#{key}=", val) } + end + end + + describe 'inheritance' do + it 'is a subclass of AbstractAdapter' do + expect(described_class).to(be < Apartment::Adapters::AbstractAdapter) + end + end + + describe '#resolve_connection_config' do + it 'returns config with database key set to tenant name (nil strategy = plain name)' do + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('acme')) + end + + it 'stringifies all config keys' do + result = adapter.resolve_connection_config('acme') + + expect(result.keys).to(all(be_a(String))) + expect(result['adapter']).to(eq('postgresql')) + expect(result['host']).to(eq('localhost')) + end + + it 'uses environmentify with :prepend strategy' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('staging')) + + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('staging_acme')) + end + + it 'uses environmentify with :append strategy' do + reconfigure(environmentify_strategy: :append) + allow(Rails).to(receive(:env).and_return('staging')) + + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('acme_staging')) + end + + it 'preserves all original connection config keys' do + config = { adapter: 'postgresql', host: 'db.example.com', database: 'app', port: 5432, pool: 10 } + local_adapter = described_class.new(config) + + result = local_adapter.resolve_connection_config('tenant1') + + expect(result['port']).to(eq(5432)) + expect(result['pool']).to(eq(10)) + expect(result['host']).to(eq('db.example.com')) + end + + it 'does not mutate the original connection_config' do + adapter.resolve_connection_config('acme') + + expect(adapter.connection_config).to(eq(connection_config)) + # The original hash should still have the original database value + expect(adapter.connection_config[:database]).to(eq('myapp')) + end + end + + describe '#create (via create_tenant)' do + let(:connection) { double('Connection') } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + end + + it 'executes CREATE DATABASE with quoted environmentified name' do + allow(connection).to(receive(:quote_table_name).with('acme').and_return('"acme"')) + expect(connection).to(receive(:execute).with('CREATE DATABASE "acme"')) + + adapter.create('acme') + end + + it 'quotes tenant names that need escaping' do + allow(connection).to(receive(:quote_table_name).with('my-tenant').and_return('"my-tenant"')) + expect(connection).to(receive(:execute).with('CREATE DATABASE "my-tenant"')) + + adapter.create('my-tenant') + end + + it 'uses environmentified name for CREATE DATABASE' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('test')) + allow(connection).to(receive(:quote_table_name).with('test_acme').and_return('"test_acme"')) + expect(connection).to(receive(:execute).with('CREATE DATABASE "test_acme"')) + + adapter.create('acme') + end + end + + describe '#drop (via drop_tenant)' do + let(:connection) { double('Connection') } + let(:pool_manager) { Apartment.pool_manager } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(pool_manager).to(receive(:remove_tenant).and_return([])) + allow(Apartment).to(receive(:deregister_shard)) + end + + it 'executes DROP DATABASE IF EXISTS with quoted environmentified name' do + allow(connection).to(receive(:quote_table_name).with('acme').and_return('"acme"')) + expect(connection).to(receive(:execute).with('DROP DATABASE IF EXISTS "acme"')) + + adapter.drop('acme') + end + + it 'quotes tenant names that need escaping' do + allow(connection).to(receive(:quote_table_name).with('my-tenant').and_return('"my-tenant"')) + expect(connection).to(receive(:execute).with('DROP DATABASE IF EXISTS "my-tenant"')) + + adapter.drop('my-tenant') + end + + it 'uses environmentified name for DROP DATABASE' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('test')) + allow(connection).to(receive(:quote_table_name).with('test_acme').and_return('"test_acme"')) + expect(connection).to(receive(:execute).with('DROP DATABASE IF EXISTS "test_acme"')) + + adapter.drop('acme') + end + end +end diff --git a/spec/unit/adapters/postgresql_schema_adapter_spec.rb b/spec/unit/adapters/postgresql_schema_adapter_spec.rb new file mode 100644 index 00000000..6a5a1c56 --- /dev/null +++ b/spec/unit/adapters/postgresql_schema_adapter_spec.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/adapters/postgresql_schema_adapter' + +# Minimal ActiveRecord stubs for SQL execution tests. +unless defined?(ActiveRecord::Base) + module ActiveRecord + class Base + def self.connection + raise('stub: override with allow in tests') + end + end + end +end + +RSpec.describe(Apartment::Adapters::PostgresqlSchemaAdapter) do + let(:connection_config) { { adapter: 'postgresql', host: 'localhost', database: 'myapp' } } + let(:adapter) { described_class.new(connection_config) } + + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'public' + c.schema_load_strategy = nil + end + end + + # Helper: reconfigure Apartment with overrides (Config is frozen after configure, + # so we must reconfigure rather than stub individual accessors). + def reconfigure(**overrides, &block) + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'public' + c.schema_load_strategy = nil + overrides.each { |key, val| c.send(:"#{key}=", val) } + block&.call(c) + end + end + + describe 'inheritance' do + it 'is a subclass of AbstractAdapter' do + expect(described_class).to(be < Apartment::Adapters::AbstractAdapter) + end + end + + describe '#resolve_connection_config' do + it 'returns config with schema_search_path set to tenant name' do + result = adapter.resolve_connection_config('acme') + + expect(result['schema_search_path']).to(eq('acme')) + end + + it 'stringifies all config keys' do + result = adapter.resolve_connection_config('acme') + + expect(result.keys).to(all(be_a(String))) + expect(result['adapter']).to(eq('postgresql')) + expect(result['host']).to(eq('localhost')) + expect(result['database']).to(eq('myapp')) + end + + it 'includes persistent_schemas when postgres_config is set' do + reconfigure do |c| + c.configure_postgres do |pg| + pg.persistent_schemas = %w[shared extensions] + end + end + + result = adapter.resolve_connection_config('acme') + + expect(result['schema_search_path']).to(eq('acme,shared,extensions')) + end + + it 'works when no postgres_config is set (nil persistent schemas)' do + # Default config has postgres_config = nil + expect(Apartment.config.postgres_config).to(be_nil) + + result = adapter.resolve_connection_config('acme') + + expect(result['schema_search_path']).to(eq('acme')) + end + + it 'works when postgres_config exists but persistent_schemas is empty' do + reconfigure do |c| + c.configure_postgres do |pg| + pg.persistent_schemas = [] + end + end + + result = adapter.resolve_connection_config('acme') + + expect(result['schema_search_path']).to(eq('acme')) + end + + it 'preserves all original connection config keys' do + config = { adapter: 'postgresql', host: 'db.example.com', database: 'app', port: 5432, pool: 10 } + local_adapter = described_class.new(config) + + result = local_adapter.resolve_connection_config('tenant1') + + expect(result['port']).to(eq(5432)) + expect(result['pool']).to(eq(10)) + end + + it 'does not mutate the original connection_config' do + adapter.resolve_connection_config('acme') + + expect(adapter.connection_config).to(eq(connection_config)) + expect(adapter.connection_config).not_to(have_key('schema_search_path')) + expect(adapter.connection_config).not_to(have_key(:schema_search_path)) + end + end + + describe '#create (via create_tenant)' do + let(:connection) { double('Connection') } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + end + + it 'executes CREATE SCHEMA with quoted tenant name' do + allow(connection).to(receive(:quote_table_name).with('acme').and_return('"acme"')) + expect(connection).to(receive(:execute).with('CREATE SCHEMA IF NOT EXISTS "acme"')) + + adapter.create('acme') + end + + it 'uses raw tenant name, not environmentified (schemas are named directly)' do + reconfigure(environmentify_strategy: :prepend) + # Schema names are NOT environmentified — unlike database-per-tenant adapters. + # The schema lives inside an already-environment-specific database. + allow(connection).to(receive(:quote_table_name).with('acme').and_return('"acme"')) + expect(connection).to(receive(:execute).with('CREATE SCHEMA IF NOT EXISTS "acme"')) + + adapter.create('acme') + end + + it 'quotes tenant names that need escaping' do + allow(connection).to(receive(:quote_table_name).with('my-tenant').and_return('"my-tenant"')) + expect(connection).to(receive(:execute).with('CREATE SCHEMA IF NOT EXISTS "my-tenant"')) + + adapter.create('my-tenant') + end + end + + describe '#drop (via drop_tenant)' do + let(:connection) { double('Connection') } + let(:pool_manager) { Apartment.pool_manager } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(pool_manager).to(receive(:remove_tenant).and_return([])) + allow(Apartment).to(receive(:deregister_shard)) + end + + it 'executes DROP SCHEMA IF EXISTS CASCADE with quoted tenant name' do + allow(connection).to(receive(:quote_table_name).with('acme').and_return('"acme"')) + expect(connection).to(receive(:execute).with('DROP SCHEMA IF EXISTS "acme" CASCADE')) + + adapter.drop('acme') + end + + it 'quotes tenant names that need escaping' do + allow(connection).to(receive(:quote_table_name).with('my-tenant').and_return('"my-tenant"')) + expect(connection).to(receive(:execute).with('DROP SCHEMA IF EXISTS "my-tenant" CASCADE')) + + adapter.drop('my-tenant') + end + + it 'uses raw tenant name, not environmentified' do + reconfigure(environmentify_strategy: :prepend) + allow(connection).to(receive(:quote_table_name).with('acme').and_return('"acme"')) + expect(connection).to(receive(:execute).with('DROP SCHEMA IF EXISTS "acme" CASCADE')) + + adapter.drop('acme') + end + end + + describe '#grant_privileges (private)' do + let(:connection) { double('Connection') } + + before do + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(connection).to(receive(:quote_table_name).with('acme').and_return('"acme"')) + allow(connection).to(receive(:quote_table_name).with('app_user').and_return('"app_user"')) + # Allow CREATE SCHEMA call from create_tenant + allow(connection).to(receive(:execute).with('CREATE SCHEMA IF NOT EXISTS "acme"')) + end + + it 'executes exactly 6 SQL statements when app_role is set' do + reconfigure(app_role: 'app_user') + expect(connection).to(receive(:execute).exactly(6).times) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'includes GRANT USAGE ON SCHEMA' do + expect(connection).to(receive(:execute).with('GRANT USAGE ON SCHEMA "acme" TO "app_user"')) + allow(connection).to(receive(:execute)) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'includes ALTER DEFAULT PRIVILEGES for tables' do + expect(connection).to(receive(:execute) + .with('ALTER DEFAULT PRIVILEGES IN SCHEMA "acme" ' \ + 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "app_user"')) + allow(connection).to(receive(:execute)) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'includes ALTER DEFAULT PRIVILEGES for sequences' do + expect(connection).to(receive(:execute) + .with('ALTER DEFAULT PRIVILEGES IN SCHEMA "acme" ' \ + 'GRANT USAGE, SELECT ON SEQUENCES TO "app_user"')) + allow(connection).to(receive(:execute)) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'includes ALTER DEFAULT PRIVILEGES for functions' do + expect(connection).to(receive(:execute) + .with('ALTER DEFAULT PRIVILEGES IN SCHEMA "acme" ' \ + 'GRANT EXECUTE ON FUNCTIONS TO "app_user"')) + allow(connection).to(receive(:execute)) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'includes GRANT on ALL TABLES' do + expect(connection).to(receive(:execute) + .with('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA "acme" TO "app_user"')) + allow(connection).to(receive(:execute)) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + + it 'includes GRANT USAGE, SELECT on ALL SEQUENCES' do + expect(connection).to(receive(:execute) + .with('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "acme" TO "app_user"')) + allow(connection).to(receive(:execute)) + + adapter.send(:grant_privileges, 'acme', connection, 'app_user') + end + end + + describe '#validated_connection_config with base_config_override' do + it 'uses the override host and username instead of adapter base_config' do + override_config = { + 'adapter' => 'postgresql', + 'host' => 'replica.example.com', + 'username' => 'readonly', + 'database' => 'myapp', + } + + result = adapter.validated_connection_config('acme', base_config_override: override_config) + + expect(result['host']).to(eq('replica.example.com')) + expect(result['username']).to(eq('readonly')) + expect(result['schema_search_path']).to(eq('acme')) + end + + it 'falls back to adapter base_config when override is nil' do + result = adapter.validated_connection_config('acme', base_config_override: nil) + + expect(result['host']).to(eq('localhost')) + expect(result['schema_search_path']).to(eq('acme')) + end + end +end diff --git a/spec/unit/adapters/sqlite3_adapter_spec.rb b/spec/unit/adapters/sqlite3_adapter_spec.rb new file mode 100644 index 00000000..3f8c3c79 --- /dev/null +++ b/spec/unit/adapters/sqlite3_adapter_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/adapters/sqlite3_adapter' + +# Minimal Rails stub for environmentify tests. +unless defined?(Rails) + module Rails + def self.env + 'test' + end + end +end + +RSpec.describe(Apartment::Adapters::Sqlite3Adapter) do + let(:connection_config) { { adapter: 'sqlite3', database: 'db/myapp.sqlite3' } } + let(:adapter) { described_class.new(connection_config) } + + before do + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'myapp' + c.schema_load_strategy = nil + end + end + + # Helper: reconfigure Apartment with overrides (Config is frozen after configure, + # so we must reconfigure rather than stub individual accessors). + def reconfigure(**overrides) + Apartment.configure do |c| + c.tenant_strategy = :database_name + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'myapp' + c.schema_load_strategy = nil + overrides.each { |key, val| c.send(:"#{key}=", val) } + end + end + + describe 'inheritance' do + it 'is a subclass of AbstractAdapter' do + expect(described_class).to(be < Apartment::Adapters::AbstractAdapter) + end + end + + describe '#resolve_connection_config' do + it 'returns config with database key set to file path' do + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('db/acme.sqlite3')) + end + + it 'stringifies all config keys' do + result = adapter.resolve_connection_config('acme') + + expect(result.keys).to(all(be_a(String))) + expect(result['adapter']).to(eq('sqlite3')) + end + + it 'uses environmentify with :prepend strategy' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('staging')) + + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('db/staging_acme.sqlite3')) + end + + it 'uses environmentify with :append strategy' do + reconfigure(environmentify_strategy: :append) + allow(Rails).to(receive(:env).and_return('staging')) + + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('db/acme_staging.sqlite3')) + end + + it 'uses environmentify with callable strategy' do + reconfigure(environmentify_strategy: ->(t) { "custom_#{t}" }) + + result = adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('db/custom_acme.sqlite3')) + end + + it 'preserves all original connection config keys' do + config = { adapter: 'sqlite3', database: 'db/app.sqlite3', pool: 5, timeout: 5000 } + local_adapter = described_class.new(config) + + result = local_adapter.resolve_connection_config('tenant1') + + expect(result['pool']).to(eq(5)) + expect(result['timeout']).to(eq(5000)) + end + + it 'does not mutate the original connection_config' do + adapter.resolve_connection_config('acme') + + expect(adapter.connection_config).to(eq(connection_config)) + expect(adapter.connection_config[:database]).to(eq('db/myapp.sqlite3')) + end + end + + describe '#database_file (via resolve_connection_config)' do + it 'constructs path from base_config database directory + environmentified tenant + .sqlite3' do + config = { adapter: 'sqlite3', database: '/var/data/app.sqlite3' } + local_adapter = described_class.new(config) + + result = local_adapter.resolve_connection_config('tenant1') + + expect(result['database']).to(eq('/var/data/tenant1.sqlite3')) + end + + it 'falls back to db/ directory when base_config has no database key' do + local_adapter = described_class.new({ adapter: 'sqlite3' }) + + result = local_adapter.resolve_connection_config('acme') + + expect(result['database']).to(eq('db/acme.sqlite3')) + end + end + + describe '#create (via create_tenant)' do + before do + allow(Apartment::Instrumentation).to(receive(:instrument)) + end + + it 'calls FileUtils.mkdir_p on the database file directory' do + expect(FileUtils).to(receive(:mkdir_p).with('db')) + + adapter.create('acme') + end + + it 'creates directory for nested database paths' do + config = { adapter: 'sqlite3', database: '/var/data/tenants/app.sqlite3' } + local_adapter = described_class.new(config) + + expect(FileUtils).to(receive(:mkdir_p).with('/var/data/tenants')) + + local_adapter.create('acme') + end + end + + describe '#drop (via drop_tenant)' do + let(:pool_manager) { Apartment.pool_manager } + + before do + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(pool_manager).to(receive(:remove_tenant).and_return([])) + allow(Apartment).to(receive(:deregister_shard)) + end + + it 'calls FileUtils.rm_f on the database file' do + expect(FileUtils).to(receive(:rm_f).with('db/acme.sqlite3')) + + adapter.drop('acme') + end + + it 'does not raise if the file does not exist (rm_f is idempotent)' do + allow(FileUtils).to(receive(:rm_f)) + + expect { adapter.drop('acme') }.not_to(raise_error) + end + + it 'uses environmentified name for file path' do + reconfigure(environmentify_strategy: :prepend) + allow(Rails).to(receive(:env).and_return('test')) + + expect(FileUtils).to(receive(:rm_f).with('db/test_acme.sqlite3')) + + adapter.drop('acme') + end + end +end diff --git a/spec/unit/apartment_spec.rb b/spec/unit/apartment_spec.rb new file mode 100644 index 00000000..d49fdbd2 --- /dev/null +++ b/spec/unit/apartment_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Minimal ActiveRecord stub for unit tests (no Rails loaded). +unless defined?(ActiveRecord::Base) + module ActiveRecord + class Base + def self.connection_db_config + raise('Stub: ActiveRecord::Base.connection_db_config not mocked') + end + end + end +end + +RSpec.describe(Apartment) do + describe '.adapter' do + context 'when not configured' do + it 'raises ConfigurationError' do + expect { described_class.adapter }.to(raise_error( + Apartment::ConfigurationError, /not configured/ + )) + end + end + + context 'when manually set' do + before do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + end + + it 'returns the manually set adapter' do + mock = double('Adapter') + described_class.adapter = mock + expect(described_class.adapter).to(eq(mock)) + end + end + + context 'caching behavior' do + before do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + end + + it 'returns the same instance on subsequent calls' do + mock = double('Adapter') + described_class.adapter = mock + expect(described_class.adapter).to(equal(described_class.adapter)) + end + end + end + + describe '.clear_config' do + it 'resets the adapter' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + described_class.adapter = double('Adapter') + + described_class.clear_config + + expect { described_class.adapter }.to(raise_error(Apartment::ConfigurationError)) + end + end + + describe '.configure' do + it 'resets the adapter so it will be rebuilt' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + described_class.adapter = double('Adapter') + + # Reconfiguring should clear the cached adapter + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + # The adapter ivar should be nil (cleared), so lazy build will be attempted + # on next access. Verify the old mock is gone by checking the ivar directly. + expect(described_class.instance_variable_get(:@adapter)).to(be_nil) + end + end + + describe '.pool_reaper' do + it 'is nil before configure' do + expect(described_class.pool_reaper).to(be_nil) + end + + it 'is an instance of PoolReaper after configure' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + expect(described_class.pool_reaper).to(be_a(Apartment::PoolReaper)) + end + + it 'is running after configure' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + expect(described_class.pool_reaper).to(be_running) + end + + it 'is nil after clear_config' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + described_class.clear_config + expect(described_class.pool_reaper).to(be_nil) + end + end + + describe '.configure teardown protection' do + it 'completes reconfigure even if reaper stop raises' do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + allow(described_class.pool_reaper).to(receive(:stop).and_raise(RuntimeError, 'timer boom')) + + expect do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'new_default' + end + end.not_to(raise_error) + + expect(described_class.config.default_tenant).to(eq('new_default')) + end + end + + describe 'build_adapter (private)' do + before do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + end + + context 'strategy resolution' do + let(:db_config) { double('db_config', adapter: 'postgresql', configuration_hash: { adapter: 'postgresql' }) } + + before do + allow(ActiveRecord::Base).to(receive(:connection_db_config).and_return(db_config)) + end + + it 'requires postgresql_schema_adapter for :schema strategy' do + adapter = described_class.send(:build_adapter) + expect(adapter).to(be_a(Apartment::Adapters::PostgresqlSchemaAdapter)) + end + + context 'with :database_name strategy' do + before do + described_class.configure do |config| + config.tenant_strategy = :database_name + config.tenants_provider = -> { [] } + end + end + + it 'instantiates PostgresqlDatabaseAdapter for postgresql' do + allow(db_config).to(receive_messages(adapter: 'postgresql', configuration_hash: { adapter: 'postgresql' })) + + adapter = described_class.send(:build_adapter) + expect(adapter).to(be_a(Apartment::Adapters::PostgresqlDatabaseAdapter)) + end + + it 'instantiates PostgresqlDatabaseAdapter for postgis' do + allow(db_config).to(receive_messages(adapter: 'postgis', configuration_hash: { adapter: 'postgis' })) + + adapter = described_class.send(:build_adapter) + expect(adapter).to(be_a(Apartment::Adapters::PostgresqlDatabaseAdapter)) + end + + it 'instantiates Mysql2Adapter for mysql2' do + allow(db_config).to(receive_messages(adapter: 'mysql2', configuration_hash: { adapter: 'mysql2' })) + + adapter = described_class.send(:build_adapter) + expect(adapter).to(be_a(Apartment::Adapters::Mysql2Adapter)) + end + + it 'instantiates TrilogyAdapter for trilogy' do + allow(db_config).to(receive_messages(adapter: 'trilogy', configuration_hash: { adapter: 'trilogy' })) + + adapter = described_class.send(:build_adapter) + expect(adapter).to(be_a(Apartment::Adapters::TrilogyAdapter)) + end + + it 'instantiates Sqlite3Adapter for sqlite3' do + allow(db_config).to(receive_messages(adapter: 'sqlite3', configuration_hash: { adapter: 'sqlite3' })) + + adapter = described_class.send(:build_adapter) + expect(adapter).to(be_a(Apartment::Adapters::Sqlite3Adapter)) + end + + it 'raises AdapterNotFound for unknown database adapter' do + allow(db_config).to(receive(:adapter).and_return('oracle')) + expect { described_class.send(:build_adapter) }.to( + raise_error(Apartment::AdapterNotFound, /No adapter for database: oracle/) + ) + end + end + + context 'with unsupported strategy' do + it 'raises AdapterNotFound for :shard strategy' do + described_class.configure do |config| + config.tenant_strategy = :shard + config.tenants_provider = -> { [] } + end + expect { described_class.send(:build_adapter) }.to( + raise_error(Apartment::AdapterNotFound, /Strategy shard not yet implemented/) + ) + end + + it 'raises AdapterNotFound for :database_config strategy' do + described_class.configure do |config| + config.tenant_strategy = :database_config + config.tenants_provider = -> { [] } + end + expect { described_class.send(:build_adapter) }.to( + raise_error(Apartment::AdapterNotFound, /Strategy database_config not yet implemented/) + ) + end + end + end + + context 'with a concrete adapter class available' do + let(:db_config) { double('db_config', adapter: 'postgresql', configuration_hash: { adapter: 'postgresql' }) } + let(:fake_adapter_instance) { double('adapter_instance') } + let(:fake_adapter_class) { class_double('Apartment::Adapters::PostgresqlSchemaAdapter').as_stubbed_const } + + before do + allow(ActiveRecord::Base).to(receive(:connection_db_config).and_return(db_config)) + allow(described_class).to(receive(:require_relative)) + stub_const('Apartment::Adapters::PostgresqlSchemaAdapter', fake_adapter_class) + allow(fake_adapter_class).to(receive(:new).and_return(fake_adapter_instance)) + end + + it 'instantiates the adapter with the connection configuration hash' do + result = described_class.send(:build_adapter) + expect(fake_adapter_class).to(have_received(:new).with({ adapter: 'postgresql' })) + expect(result).to(eq(fake_adapter_instance)) + end + + it 'caches the adapter on subsequent .adapter calls' do + first = described_class.adapter + second = described_class.adapter + expect(first).to(equal(second)) + expect(fake_adapter_class).to(have_received(:new).once) + end + end + end + + describe '.deregister_shard' do + before do + described_class.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + end + + it 'calls remove_connection_pool with the role parsed from the composite key' do + handler = instance_double('ActiveRecord::ConnectionAdapters::ConnectionHandler') + allow(ActiveRecord::Base).to(receive(:connection_handler).and_return(handler)) + allow(handler).to(receive(:remove_connection_pool)) + allow(ActiveRecord).to(receive(:writing_role).and_return(:writing)) + + described_class.deregister_shard('acme:db_manager') + + prefix = described_class.config.shard_key_prefix + expect(handler).to(have_received(:remove_connection_pool).with( + 'ActiveRecord::Base', + role: :db_manager, + shard: :"#{prefix}_acme:db_manager" + )) + end + + it 'falls back to writing_role when pool_key has no colon' do + handler = instance_double('ActiveRecord::ConnectionAdapters::ConnectionHandler') + allow(ActiveRecord::Base).to(receive(:connection_handler).and_return(handler)) + allow(handler).to(receive(:remove_connection_pool)) + allow(ActiveRecord).to(receive(:writing_role).and_return(:writing)) + + described_class.deregister_shard('acme') + + prefix = described_class.config.shard_key_prefix + expect(handler).to(have_received(:remove_connection_pool).with( + 'ActiveRecord::Base', + role: :writing, + shard: :"#{prefix}_acme" + )) + end + + it 'is a no-op when config is nil' do + described_class.clear_config + expect { described_class.deregister_shard('acme') }.not_to(raise_error) + end + end + + describe '.detect_database_adapter (private)' do + it 'returns the adapter string from ActiveRecord connection config' do + db_config = double('db_config', adapter: 'postgresql') + allow(ActiveRecord::Base).to(receive(:connection_db_config).and_return(db_config)) + + result = described_class.send(:detect_database_adapter) + expect(result).to(eq('postgresql')) + end + end +end diff --git a/spec/unit/cli/migrations_spec.rb b/spec/unit/cli/migrations_spec.rb new file mode 100644 index 00000000..25c2bc31 --- /dev/null +++ b/spec/unit/cli/migrations_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Migrations) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'migrate' do + let(:migration_run) do + Apartment::Migrator::MigrationRun.new( + results: [ + Apartment::Migrator::Result.new( + tenant: 'public', status: :success, duration: 0.1, error: nil, versions_run: [] + ), + Apartment::Migrator::Result.new( + tenant: 'acme', status: :success, duration: 0.2, error: nil, versions_run: [] + ), + ], + total_duration: 0.3, + threads: 0 + ) + end + + context 'without tenant argument (all tenants)' do + before do + allow(Apartment::Migrator).to(receive(:new).and_return(double(run: migration_run))) + allow(ActiveRecord).to(receive(:dump_schema_after_migration).and_return(false)) + end + + it 'delegates to Migrator#run' do + run_command('migrate') + expect(Apartment::Migrator).to(have_received(:new)) + end + + it 'prints the migration summary' do + output = run_command('migrate') + expect(output).to(include('tenants')) + end + + it 'passes --threads to Migrator' do + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(threads: 4)).and_return(double(run: migration_run))) + run_command('migrate', '--threads=4') + end + + it 'passes --version to Migrator' do + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(version: 20_260_401)).and_return(double(run: migration_run))) + run_command('migrate', '--version=20260401') + end + + it 'falls back to ENV VERSION when --version not given' do + original = ENV.fetch('VERSION', nil) + ENV['VERSION'] = '20260401' + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(version: 20_260_401)).and_return(double(run: migration_run))) + run_command('migrate') + ensure + ENV['VERSION'] = original + end + + it 'defaults threads to config value' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme] } + c.default_tenant = 'public' + c.parallel_migration_threads = 8 + end + expect(Apartment::Migrator).to(receive(:new) + .with(hash_including(threads: 8)).and_return(double(run: migration_run))) + run_command('migrate') + end + + it 'exits non-zero when migration fails' do + failed_run = Apartment::Migrator::MigrationRun.new( + results: [ + Apartment::Migrator::Result.new( + tenant: 'acme', status: :failed, duration: 0.1, + error: StandardError.new('boom'), versions_run: [] + ), + ], + total_duration: 0.1, + threads: 0 + ) + allow(Apartment::Migrator).to(receive(:new).and_return(double(run: failed_run))) + expect { run_command('migrate') }.to(raise_error(SystemExit)) + end + end + + context 'with tenant argument (single tenant)' do + let(:result) do + Apartment::Migrator::Result.new( + tenant: 'acme', status: :success, duration: 0.2, error: nil, versions_run: [1] + ) + end + + before do + allow(Apartment::Migrator).to(receive(:new).and_return(double(migrate_one: result))) + end + + it 'delegates to Migrator#migrate_one' do + migrator = double + allow(Apartment::Migrator).to(receive(:new).and_return(migrator)) + expect(migrator).to(receive(:migrate_one).with('acme').and_return(result)) + run_command('migrate', 'acme') + end + + it 'prints success message' do + output = run_command('migrate', 'acme') + expect(output).to(include('acme')) + end + + it 'exits non-zero on failure' do + failed = Apartment::Migrator::Result.new( + tenant: 'acme', status: :failed, duration: 0.1, + error: StandardError.new('boom'), versions_run: [] + ) + allow(Apartment::Migrator).to(receive(:new).and_return(double(migrate_one: failed))) + expect { run_command('migrate', 'acme') }.to(raise_error(SystemExit)) + end + end + end + + describe 'rollback' do + let(:mock_migration_context) { double('MigrationContext') } + let(:mock_pool) { double('pool', migration_context: mock_migration_context) } + + before do + allow(mock_migration_context).to(receive(:rollback)) + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(mock_pool)) + allow(Apartment::Tenant).to(receive(:switch)) { |_t, &block| block.call } + end + + it 'rolls back all tenants by default' do + run_command('rollback') + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + expect(Apartment::Tenant).to(have_received(:switch).with('beta')) + expect(mock_migration_context).to(have_received(:rollback).with(1).twice) + end + + it 'rolls back a single tenant when given' do + run_command('rollback', 'acme') + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + expect(Apartment::Tenant).not_to(have_received(:switch).with('beta')) + end + + it 'respects --step option' do + run_command('rollback', '--step=3') + expect(mock_migration_context).to(have_received(:rollback).with(3).twice) + end + + it 'exits non-zero when a tenant fails' do + allow(Apartment::Tenant).to(receive(:switch).with('acme') + .and_raise(StandardError, 'boom')) + allow(Apartment::Tenant).to(receive(:switch).with('beta')) { |_t, &block| block.call } + expect { run_command('rollback') }.to(raise_error(SystemExit)) + end + + context 'with migration_role configured' do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + c.migration_role = :db_manager + end + allow(ActiveRecord::Base).to(receive(:connected_to).and_yield) + end + + it 'wraps single-tenant rollback in connected_to with migration_role' do + run_command('rollback', 'acme') + expect(ActiveRecord::Base).to(have_received(:connected_to).with(role: :db_manager)) + end + + it 'wraps all-tenant rollback in connected_to with migration_role' do + run_command('rollback') + expect(ActiveRecord::Base).to(have_received(:connected_to).with(role: :db_manager).twice) + end + end + + context 'without migration_role' do + it 'does not call connected_to' do + expect(ActiveRecord::Base).not_to(receive(:connected_to)) + run_command('rollback') + end + end + end +end diff --git a/spec/unit/cli/pool_spec.rb b/spec/unit/cli/pool_spec.rb new file mode 100644 index 00000000..1f73a68f --- /dev/null +++ b/spec/unit/cli/pool_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Pool) do + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'stats' do + context 'when pool_manager is configured' do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + it 'prints pool summary' do + Apartment.pool_manager.fetch_or_create('acme') { double('pool') } + output = run_command('stats') + expect(output).to(include('pool')) + end + + it 'prints per-tenant details with --verbose' do + Apartment.pool_manager.fetch_or_create('acme') { double('pool') } + output = run_command('stats', '--verbose') + expect(output).to(include('acme')) + end + end + + context 'when pool_manager is nil' do + before { Apartment.clear_config } + + it 'prints a not-configured message' do + output = run_command('stats') + expect(output).to(include('not configured')) + end + end + end + + describe 'evict' do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + end + + it 'runs eviction cycle with --force' do + allow(Apartment.pool_reaper).to(receive(:run_cycle).and_return(3)) + output = run_command('evict', '--force') + expect(output).to(include('3')) + expect(Apartment.pool_reaper).to(have_received(:run_cycle)) + end + + it 'prompts without --force' do + instance = described_class.new + allow(instance).to(receive(:yes?).and_return(false)) + allow(instance).to(receive(:say)) + allow(Apartment.pool_reaper).to(receive(:run_cycle)) + instance.evict + expect(Apartment.pool_reaper).not_to(have_received(:run_cycle)) + end + + it 'reports when pool_reaper is nil' do + Apartment.clear_config + output = run_command('evict', '--force') + expect(output).to(include('not configured')) + end + end +end diff --git a/spec/unit/cli/seeds_spec.rb b/spec/unit/cli/seeds_spec.rb new file mode 100644 index 00000000..62b7534e --- /dev/null +++ b/spec/unit/cli/seeds_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Seeds) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + allow(Apartment::Tenant).to(receive(:seed)) + end + + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'seed' do + it 'seeds a single tenant when given an argument' do + run_command('seed', 'acme') + expect(Apartment::Tenant).to(have_received(:seed).with('acme')) + end + + it 'seeds all tenants when no argument given' do + run_command('seed') + expect(Apartment::Tenant).to(have_received(:seed).with('acme')) + expect(Apartment::Tenant).to(have_received(:seed).with('beta')) + end + + it 'collects errors and exits non-zero' do + allow(Apartment::Tenant).to(receive(:seed).with('acme') + .and_raise(StandardError, 'seed error')) + expect { run_command('seed') }.to(raise_error(SystemExit)) + end + + it 'prints per-tenant output' do + output = run_command('seed') + expect(output).to(include('acme')) + expect(output).to(include('beta')) + end + end +end diff --git a/spec/unit/cli/tenants_spec.rb b/spec/unit/cli/tenants_spec.rb new file mode 100644 index 00000000..1aad145c --- /dev/null +++ b/spec/unit/cli/tenants_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI::Tenants) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + def run_command(*args) + output = StringIO.new + $stdout = output + described_class.start(args) + output.string + ensure + $stdout = STDOUT + end + + describe 'create' do + before do + allow(Apartment::Tenant).to(receive(:create)) + end + + it 'creates a single tenant when given an argument' do + run_command('create', 'acme') + expect(Apartment::Tenant).to(have_received(:create).with('acme')) + end + + it 'creates all tenants when no argument given' do + run_command('create') + expect(Apartment::Tenant).to(have_received(:create).with('acme')) + expect(Apartment::Tenant).to(have_received(:create).with('beta')) + end + + it 'skips tenants that already exist' do + allow(Apartment::Tenant).to(receive(:create).with('acme') + .and_raise(Apartment::TenantExists.new('acme'))) + output = run_command('create') + expect(output).to(include('already exists')) + end + + it 'collects errors and reports failures' do + allow(Apartment::Tenant).to(receive(:create).with('acme') + .and_raise(StandardError, 'connection refused')) + allow(Apartment::Tenant).to(receive(:create).with('beta')) + expect { run_command('create') }.to(raise_error(SystemExit)) + end + + it 'suppresses per-tenant output with --quiet' do + output = run_command('create', '--quiet') + expect(output).not_to(include('Creating')) + end + end + + describe 'drop' do + before do + allow(Apartment::Tenant).to(receive(:drop)) + end + + it 'drops the specified tenant with --force' do + run_command('drop', 'acme', '--force') + expect(Apartment::Tenant).to(have_received(:drop).with('acme')) + end + + it 'prompts for confirmation without --force' do + instance = described_class.new + allow(instance).to(receive(:yes?).and_return(false)) + allow(instance).to(receive(:say)) + instance.drop('acme') + expect(Apartment::Tenant).not_to(have_received(:drop)) + end + + it 'proceeds when confirmation is accepted' do + instance = described_class.new + allow(instance).to(receive(:yes?).and_return(true)) + allow(instance).to(receive(:say)) + instance.drop('acme') + expect(Apartment::Tenant).to(have_received(:drop).with('acme')) + end + end + + describe 'list' do + it 'prints all tenant names' do + output = run_command('list') + expect(output).to(include('acme')) + expect(output).to(include('beta')) + end + end + + describe 'current' do + it 'prints the current tenant' do + Apartment::Current.tenant = 'acme' + output = run_command('current') + expect(output.strip).to(eq('acme')) + end + + it 'prints default_tenant when no current tenant' do + output = run_command('current') + expect(output.strip).to(eq('public')) + end + + it 'prints none when no tenant context' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + end + output = run_command('current') + expect(output.strip).to(eq('none')) + end + end + + describe 'APARTMENT_FORCE env var' do + before do + allow(Apartment::Tenant).to(receive(:drop)) + end + + it 'skips confirmation when APARTMENT_FORCE=1' do + original = ENV.fetch('APARTMENT_FORCE', nil) + ENV['APARTMENT_FORCE'] = '1' + run_command('drop', 'acme') + expect(Apartment::Tenant).to(have_received(:drop).with('acme')) + ensure + ENV['APARTMENT_FORCE'] = original + end + end + + describe 'APARTMENT_QUIET env var' do + before do + allow(Apartment::Tenant).to(receive(:create)) + end + + it 'suppresses output when APARTMENT_QUIET=1' do + original = ENV.fetch('APARTMENT_QUIET', nil) + ENV['APARTMENT_QUIET'] = '1' + output = run_command('create') + expect(output).not_to(include('Creating')) + ensure + ENV['APARTMENT_QUIET'] = original + end + end +end diff --git a/spec/unit/cli_spec.rb b/spec/unit/cli_spec.rb new file mode 100644 index 00000000..ec116046 --- /dev/null +++ b/spec/unit/cli_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/cli' + +RSpec.describe(Apartment::CLI) do + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to(be(true)) + end + end + + describe 'subcommand registration' do + it 'registers tenants subcommand' do + expect(help_output).to(include('tenants')) + end + + it 'registers migrations subcommand' do + expect(help_output).to(include('migrations')) + end + + it 'registers seeds subcommand' do + expect(help_output).to(include('seeds')) + end + + it 'registers pool subcommand' do + expect(help_output).to(include('pool')) + end + end + + private + + def help_output + @help_output ||= capture_stdout { described_class.start(['help']) } + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/unit/concerns/model_spec.rb b/spec/unit/concerns/model_spec.rb new file mode 100644 index 00000000..d7c15e65 --- /dev/null +++ b/spec/unit/concerns/model_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/apartment/concerns/model' + +RSpec.describe(Apartment::Model) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + end + end + + after do + Apartment.clear_config + end + + describe '.pin_tenant' do + it 'registers the model in Apartment.pinned_models' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedTestModel', klass) + + klass.pin_tenant + + expect(Apartment.pinned_models).to(include(PinnedTestModel)) + end + + it 'is idempotent — second call is a no-op' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('IdempotentModel', klass) + + klass.pin_tenant + klass.pin_tenant + + expect(Apartment.pinned_models.count { |m| m == IdempotentModel }).to(eq(1)) + end + + it 'processes immediately when Apartment is already activated' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('LateLoadedModel', klass) + + expect(Apartment).to(receive(:activated?).and_return(true)) + expect(Apartment).to(receive(:process_pinned_model).with(LateLoadedModel)) + + klass.pin_tenant + end + + it 'defers processing when Apartment is not yet activated' do + expect(Apartment).to(receive(:activated?).and_return(false)) + expect(Apartment).not_to(receive(:process_pinned_model)) + + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('EarlyModel', klass) + + klass.pin_tenant + end + end + + describe '.apartment_pinned?' do + it 'returns false for unpinned models' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + + expect(klass.apartment_pinned?).to(be(false)) + end + + it 'returns true after pin_tenant' do + klass = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedCheck', klass) + + klass.pin_tenant + expect(klass.apartment_pinned?).to(be(true)) + end + + it 'returns true for subclass of pinned model (STI)' do + parent = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedParent', parent) + parent.pin_tenant + + child = Class.new(parent) + stub_const('PinnedChild', child) + + expect(child.apartment_pinned?).to(be(true)) + end + + it 'returns false for classes without the concern' do + klass = Class.new(ActiveRecord::Base) + + expect(klass.respond_to?(:apartment_pinned?)).to(be(false)) + end + end +end diff --git a/spec/unit/config_spec.rb b/spec/unit/config_spec.rb index 9adaf84c..bc9f73ce 100644 --- a/spec/unit/config_spec.rb +++ b/spec/unit/config_spec.rb @@ -2,124 +2,486 @@ require 'spec_helper' -describe Apartment do - describe '#config' do - let(:excluded_models) { ['Company'] } - let(:seed_data_file_path) { Rails.root.join('db', 'seeds', 'import.rb') } +RSpec.describe(Apartment::Config) do + subject(:config) { described_class.new } - def tenant_names_from_array(names) - names.each_with_object({}) do |tenant, hash| - hash[tenant] = Apartment.connection_config - end.with_indifferent_access - end + describe 'defaults' do + it { expect(config.tenant_strategy).to(be_nil) } + it { expect(config.tenants_provider).to(be_nil) } + it { expect(config.default_tenant).to(be_nil) } + it { expect(config.excluded_models).to(eq([])) } + it { expect(config.tenant_pool_size).to(eq(5)) } + it { expect(config.pool_idle_timeout).to(eq(300)) } + it { expect(config.max_total_connections).to(be_nil) } + it { expect(config.seed_after_create).to(be(false)) } + it { expect(config.seed_data_file).to(be_nil) } + it { expect(config.parallel_migration_threads).to(eq(0)) } + it { expect(config.environmentify_strategy).to(be_nil) } + it { expect(config.elevator).to(be_nil) } + it { expect(config.elevator_options).to(eq({})) } + it { expect(config.tenant_not_found_handler).to(be_nil) } + it { expect(config.active_record_log).to(be(false)) } + it { expect(config.postgres_config).to(be_nil) } + it { expect(config.mysql_config).to(be_nil) } + it { expect(config.shard_key_prefix).to(eq('apartment')) } + end - it 'should yield the Apartment object' do - Apartment.configure do |config| - config.excluded_models = [] - expect(config).to eq(Apartment) + describe '#tenant_strategy=' do + it 'accepts valid strategies' do + %i[schema database_name shard database_config].each do |strategy| + expect { config.tenant_strategy = strategy }.not_to(raise_error) end end - it 'should set excluded models' do - Apartment.configure do |config| - config.excluded_models = excluded_models - end - expect(Apartment.excluded_models).to eq(excluded_models) + it 'rejects invalid strategies' do + expect { config.tenant_strategy = :invalid }.to(raise_error( + Apartment::ConfigurationError, /Invalid tenant_strategy/ + )) end + end - it 'should set use_schemas' do - Apartment.configure do |config| - config.excluded_models = [] - config.use_schemas = false + describe '#environmentify_strategy=' do + it 'accepts nil, :prepend, :append' do + [nil, :prepend, :append].each do |val| + expect { config.environmentify_strategy = val }.not_to(raise_error) end - expect(Apartment.use_schemas).to be false end - it 'should set seed_data_file' do - Apartment.configure do |config| - config.seed_data_file = seed_data_file_path - end - expect(Apartment.seed_data_file).to eq(seed_data_file_path) + it 'accepts a callable' do + expect { config.environmentify_strategy = ->(t) { "test_#{t}" } }.not_to(raise_error) end - it 'should set seed_after_create' do - Apartment.configure do |config| - config.excluded_models = [] - config.seed_after_create = true - end - expect(Apartment.seed_after_create).to be true + it 'rejects invalid values' do + expect { config.environmentify_strategy = :bad }.to( + raise_error(Apartment::ConfigurationError, /Invalid environmentify_strategy/) + ) end + end - it 'should set tenant_presence_check' do - Apartment.configure do |config| - config.tenant_presence_check = true + describe '#configure_postgres' do + it 'creates a PostgresqlConfig' do + pg = config.configure_postgres do |pg| + pg.persistent_schemas = ['shared'] + pg.enforce_search_path_reset = true end - expect(Apartment.tenant_presence_check).to be true + + expect(pg).to(be_a(Apartment::Configs::PostgresqlConfig)) + expect(pg.persistent_schemas).to(eq(['shared'])) + expect(pg.enforce_search_path_reset).to(be(true)) + expect(config.postgres_config).to(eq(pg)) end + end - it 'should set active_record_log' do - Apartment.configure do |config| - config.active_record_log = true - end - expect(Apartment.active_record_log).to be true + describe '#configure_mysql' do + it 'creates a MysqlConfig' do + my = config.configure_mysql + expect(my).to(be_a(Apartment::Configs::MysqlConfig)) + expect(config.mysql_config).to(eq(my)) + end + end + + describe '#validate!' do + it 'raises when tenant_strategy is missing' do + expect { config.validate! }.to(raise_error( + Apartment::ConfigurationError, /tenant_strategy is required/ + )) + end + + it 'raises when tenants_provider is not callable' do + config.tenant_strategy = :schema + config.tenants_provider = 'not_callable' + expect { config.validate! }.to(raise_error( + Apartment::ConfigurationError, /tenants_provider must be a callable/ + )) end - context 'databases' do - let(:users_conf_hash) { { port: 5444 } } + it 'raises when both postgres and mysql are configured' do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.configure_postgres + config.configure_mysql + expect { config.validate! }.to(raise_error( + Apartment::ConfigurationError, /Cannot configure both/ + )) + end + + it 'raises when tenants_provider is missing' do + config.tenant_strategy = :schema + expect { config.validate! }.to(raise_error( + Apartment::ConfigurationError, /tenants_provider/ + )) + end + + it 'raises when tenant_pool_size is not a positive integer' do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.tenant_pool_size = 0 + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /tenant_pool_size/)) + end + it 'raises when pool_idle_timeout is not a positive number' do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.pool_idle_timeout = -1 + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /pool_idle_timeout/)) + end + + it 'raises when max_total_connections is invalid' do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.max_total_connections = 0 + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /max_total_connections/)) + end + + it 'passes with valid minimal configuration' do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + expect { config.validate! }.not_to(raise_error) + end + + context 'migration_role validation' do before do - Apartment.configure do |config| - config.tenant_names = tenant_names - end + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } end - context 'tenant_names as string array' do - let(:tenant_names) { %w[users companies] } + it 'rejects a non-symbol value' do + config.migration_role = 'db_manager' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /migration_role/)) + end - it 'should return object if it doesnt respond_to call' do - expect(Apartment.tenant_names).to eq(tenant_names_from_array(tenant_names).keys) - end + it 'accepts nil' do + config.migration_role = nil + expect { config.validate! }.not_to(raise_error) + end - it 'should set tenants_with_config' do - expect(Apartment.tenants_with_config).to eq(tenant_names_from_array(tenant_names)) - end + it 'accepts a symbol' do + config.migration_role = :db_manager + expect { config.validate! }.not_to(raise_error) end + end - context 'tenant_names as proc returning an array' do - let(:tenant_names) { -> { %w[users companies] } } + context 'app_role validation' do + before do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end - it 'should return object if it doesnt respond_to call' do - expect(Apartment.tenant_names).to eq(tenant_names_from_array(tenant_names.call).keys) - end + it 'rejects a non-string non-callable value' do + config.app_role = 123 + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /app_role/)) + end - it 'should set tenants_with_config' do - expect(Apartment.tenants_with_config).to eq(tenant_names_from_array(tenant_names.call)) - end + it 'accepts nil' do + config.app_role = nil + expect { config.validate! }.not_to(raise_error) end - context 'tenant_names as Hash' do - let(:tenant_names) { { users: users_conf_hash }.with_indifferent_access } + it 'accepts a string' do + config.app_role = 'app_user' + expect { config.validate! }.not_to(raise_error) + end - it 'should return object if it doesnt respond_to call' do - expect(Apartment.tenant_names).to eq(tenant_names.keys) - end + it 'accepts a callable' do + config.app_role = -> { 'dynamic_role' } + expect { config.validate! }.not_to(raise_error) + end + end - it 'should set tenants_with_config' do - expect(Apartment.tenants_with_config).to eq(tenant_names) - end + context 'schema_cache_per_tenant validation' do + before do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } end - context 'tenant_names as proc returning a Hash' do - let(:tenant_names) { -> { { users: users_conf_hash }.with_indifferent_access } } + it 'rejects a non-boolean value' do + config.schema_cache_per_tenant = 'yes' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /schema_cache_per_tenant/)) + end - it 'should return object if it doesnt respond_to call' do - expect(Apartment.tenant_names).to eq(tenant_names.call.keys) - end + it 'accepts true' do + config.schema_cache_per_tenant = true + expect { config.validate! }.not_to(raise_error) + end - it 'should set tenants_with_config' do - expect(Apartment.tenants_with_config).to eq(tenant_names.call) - end + it 'accepts false' do + config.schema_cache_per_tenant = false + expect { config.validate! }.not_to(raise_error) end end + + context 'check_pending_migrations validation' do + before do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + it 'rejects a non-boolean value' do + config.check_pending_migrations = 1 + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /check_pending_migrations/)) + end + + it 'accepts true' do + config.check_pending_migrations = true + expect { config.validate! }.not_to(raise_error) + end + + it 'accepts false' do + config.check_pending_migrations = false + expect { config.validate! }.not_to(raise_error) + end + end + end + + describe '#shard_key_prefix validation' do + before do + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + it 'passes with the default value' do + expect { config.validate! }.not_to(raise_error) + end + + it 'passes with a custom valid prefix' do + config.shard_key_prefix = 'myapp_tenant' + expect { config.validate! }.not_to(raise_error) + end + + it 'raises ConfigurationError for empty string' do + config.shard_key_prefix = '' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end + + it 'raises ConfigurationError for string starting with a number' do + config.shard_key_prefix = '1bad' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end + + it 'raises ConfigurationError for string with special characters' do + config.shard_key_prefix = 'my-prefix' + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end + + it 'raises ConfigurationError for non-string value' do + config.shard_key_prefix = :symbol + expect { config.validate! }.to(raise_error(Apartment::ConfigurationError, /shard_key_prefix/)) + end + end + + describe 'schema_load_strategy' do + it 'defaults to nil (opt-in schema loading)' do + config = described_class.new + expect(config.schema_load_strategy).to(be_nil) + end + + it 'accepts :schema_rb' do + config = described_class.new + config.schema_load_strategy = :schema_rb + expect(config.schema_load_strategy).to(eq(:schema_rb)) + end + + it 'accepts :sql' do + config = described_class.new + config.schema_load_strategy = :sql + expect(config.schema_load_strategy).to(eq(:sql)) + end + + it 'accepts nil' do + config = described_class.new + config.schema_load_strategy = nil + expect(config.schema_load_strategy).to(be_nil) + end + + it 'rejects invalid values during validation' do + expect do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.schema_load_strategy = :invalid + end + end.to(raise_error(Apartment::ConfigurationError, /Invalid schema_load_strategy/)) + end + end + + describe 'migration_role' do + it 'defaults to nil' do + expect(config.migration_role).to(be_nil) + end + + it 'accepts nil' do + config.migration_role = nil + expect(config.migration_role).to(be_nil) + end + + it 'accepts a symbol' do + config.migration_role = :db_manager + expect(config.migration_role).to(eq(:db_manager)) + end + end + + describe 'app_role' do + it 'defaults to nil' do + expect(config.app_role).to(be_nil) + end + + it 'accepts nil' do + config.app_role = nil + expect(config.app_role).to(be_nil) + end + + it 'accepts a string' do + config.app_role = 'app_user' + expect(config.app_role).to(eq('app_user')) + end + + it 'accepts a callable' do + callable = -> { 'dynamic_role' } + config.app_role = callable + expect(config.app_role).to(eq(callable)) + end + end + + describe 'schema_cache_per_tenant' do + it 'defaults to false' do + expect(config.schema_cache_per_tenant).to(be(false)) + end + + it 'accepts true' do + config.schema_cache_per_tenant = true + expect(config.schema_cache_per_tenant).to(be(true)) + end + + it 'accepts false' do + config.schema_cache_per_tenant = false + expect(config.schema_cache_per_tenant).to(be(false)) + end + end + + describe 'check_pending_migrations' do + it 'defaults to true' do + expect(config.check_pending_migrations).to(be(true)) + end + + it 'accepts true' do + config.check_pending_migrations = true + expect(config.check_pending_migrations).to(be(true)) + end + + it 'accepts false' do + config.check_pending_migrations = false + expect(config.check_pending_migrations).to(be(false)) + end + end + + describe 'schema_file' do + it 'defaults to nil' do + config = described_class.new + expect(config.schema_file).to(be_nil) + end + + it 'accepts a string path' do + config = described_class.new + config.schema_file = '/path/to/schema.rb' + expect(config.schema_file).to(eq('/path/to/schema.rb')) + end + end + + describe '#rails_env_name' do + around do |example| + saved_rails_env = ENV.fetch('RAILS_ENV', nil) + saved_rack_env = ENV.fetch('RACK_ENV', nil) + example.run + ENV['RAILS_ENV'] = saved_rails_env + ENV['RACK_ENV'] = saved_rack_env + end + + it 'returns Rails.env when Rails is defined' do + stub_const('Rails', double(env: 'test')) + expect(config.rails_env_name).to(eq('test')) + end + + it 'falls back to RAILS_ENV env var' do + hide_const('Rails') + ENV['RAILS_ENV'] = 'staging' + ENV.delete('RACK_ENV') + expect(config.rails_env_name).to(eq('staging')) + end + + it 'falls back to RACK_ENV env var' do + hide_const('Rails') + ENV.delete('RAILS_ENV') + ENV['RACK_ENV'] = 'production' + expect(config.rails_env_name).to(eq('production')) + end + + it "defaults to 'default_env' when nothing is set" do + hide_const('Rails') + ENV.delete('RAILS_ENV') + ENV.delete('RACK_ENV') + expect(config.rails_env_name).to(eq('default_env')) + end + end +end + +RSpec.describe('Apartment.configure') do + it 'yields a Config instance and stores it' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'public' + end + + expect(Apartment.config).to(be_a(Apartment::Config)) + expect(Apartment.config.tenant_strategy).to(eq(:schema)) + expect(Apartment.config.default_tenant).to(eq('public')) + end + + it 'validates the configuration' do + expect do + Apartment.configure { |c| } # no strategy set + end.to(raise_error(Apartment::ConfigurationError)) + end + + it 'raises without a block' do + expect { Apartment.configure }.to(raise_error(Apartment::ConfigurationError, /requires a block/)) + end + + it 'freezes the config after validation' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + end + + expect(Apartment.config).to(be_frozen) + expect(Apartment.config.excluded_models).to(be_frozen) + expect { Apartment.config.default_tenant = 'x' }.to(raise_error(FrozenError)) + end + + it 'preserves previous config when reconfigure fails' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'original' + end + + expect do + Apartment.configure { |c| } # no strategy — will fail validation + end.to(raise_error(Apartment::ConfigurationError)) + + expect(Apartment.config.default_tenant).to(eq('original')) + end +end + +RSpec.describe('Apartment.clear_config') do + it 'resets config and pool_manager to nil' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + end + Apartment.clear_config + + expect(Apartment.config).to(be_nil) + expect(Apartment.pool_manager).to(be_nil) end end diff --git a/spec/unit/current_spec.rb b/spec/unit/current_spec.rb new file mode 100644 index 00000000..01b66ebf --- /dev/null +++ b/spec/unit/current_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Apartment::Current) do + after { described_class.reset } + + it 'stores and retrieves the tenant attribute' do + described_class.tenant = 'acme' + expect(described_class.tenant).to(eq('acme')) + end + + it 'stores and retrieves the previous_tenant attribute' do + described_class.previous_tenant = 'old_tenant' + expect(described_class.previous_tenant).to(eq('old_tenant')) + end + + it 'resets all attributes' do + described_class.tenant = 'acme' + described_class.previous_tenant = 'old' + described_class.reset + + expect(described_class.tenant).to(be_nil) + expect(described_class.previous_tenant).to(be_nil) + end + + it 'isolates state across threads' do + described_class.tenant = 'main_thread' + + thread_value = Thread.new do + described_class.tenant = 'other_thread' + described_class.tenant + end.value + + expect(described_class.tenant).to(eq('main_thread')) + expect(thread_value).to(eq('other_thread')) + end + + it 'stores and retrieves the migrating attribute' do + described_class.migrating = true + expect(described_class.migrating).to(be(true)) + end + + it 'defaults migrating to nil' do + expect(described_class.migrating).to(be_nil) + end + + it 'resets migrating attribute' do + described_class.migrating = true + described_class.reset + + expect(described_class.migrating).to(be_nil) + end +end diff --git a/spec/unit/elevators/domain_spec.rb b/spec/unit/elevators/domain_spec.rb index 520b315c..22154f28 100644 --- a/spec/unit/elevators/domain_spec.rb +++ b/spec/unit/elevators/domain_spec.rb @@ -1,33 +1,47 @@ # frozen_string_literal: true require 'spec_helper' +require 'rack' require 'apartment/elevators/domain' -describe Apartment::Elevators::Domain do - subject(:elevator) { described_class.new(proc {}) } +RSpec.describe(Apartment::Elevators::Domain) do + let(:inner_app) { ->(_env) { [200, {}, ['ok']] } } + + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end 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') + it 'extracts domain name from simple host' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('example.com'))).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') + it 'strips www prefix' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).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 + it 'extracts first non-www segment with subdomains' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('a.example.bc.ca'))).to(eq('a')) end - end - describe '#call' do - it 'switches to the proper tenant' do - expect(Apartment::Tenant).to receive(:switch).with('example') + it 'strips www even with complex TLD' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('www.example.bc.ca'))).to(eq('example')) + end + + it 'extracts name from single-label host' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('localhost'))).to(eq('localhost')) + end - elevator.call('HTTP_HOST' => 'www.example.com') + it 'returns nil for blank host' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('/')) + allow(request).to(receive(:host).and_return('')) + expect(elevator.parse_tenant_name(request)).to(be_nil) end end end diff --git a/spec/unit/elevators/first_subdomain_spec.rb b/spec/unit/elevators/first_subdomain_spec.rb index f608d834..7fc004a4 100644 --- a/spec/unit/elevators/first_subdomain_spec.rb +++ b/spec/unit/elevators/first_subdomain_spec.rb @@ -1,26 +1,35 @@ # frozen_string_literal: true require 'spec_helper' +require 'rack' require 'apartment/elevators/first_subdomain' -describe Apartment::Elevators::FirstSubdomain do - describe 'subdomain' do - subject { described_class.new('test').parse_tenant_name(request) } - let(:request) { double(:request, host: "#{subdomain}.example.com") } +RSpec.describe(Apartment::Elevators::FirstSubdomain) do + let(:inner_app) { ->(_env) { [200, {}, ['ok']] } } - context 'one subdomain' do - let(:subdomain) { 'test' } - it { is_expected.to eq('test') } + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end + + describe '#parse_tenant_name' do + it 'extracts the first subdomain segment' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.staging.example.com'))).to(eq('acme')) + end + + it 'works with a single subdomain' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme')) end - context 'nested subdomains' do - let(:subdomain) { 'test1.test2' } - it { is_expected.to eq('test1') } + it 'returns nil when no subdomain' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('example.com'))).to(be_nil) end - context 'no subdomain' do - let(:subdomain) { nil } - it { is_expected.to eq(nil) } + it 'respects excluded_subdomains from Subdomain' do + elevator = described_class.new(inner_app, excluded_subdomains: %w[www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(be_nil) end end end diff --git a/spec/unit/elevators/generic_spec.rb b/spec/unit/elevators/generic_spec.rb index f4112e78..45602f5c 100644 --- a/spec/unit/elevators/generic_spec.rb +++ b/spec/unit/elevators/generic_spec.rb @@ -1,57 +1,77 @@ # frozen_string_literal: true require 'spec_helper' +require 'rack' require 'apartment/elevators/generic' -describe Apartment::Elevators::Generic do - # rubocop:disable Lint/ConstantDefinitionInBlock - class MyElevator < described_class - def parse_tenant_name(*) - 'tenant2' - end - end - # rubocop:enable Lint/ConstantDefinitionInBlock - - subject(:elevator) { described_class.new(proc {}) } +RSpec.describe(Apartment::Elevators::Generic) do + let(:inner_app) { ->(env) { [200, { 'Content-Type' => 'text/plain' }, [env['apartment.tenant'] || 'default']] } } describe '#call' do - it 'calls the processor if given' do - elevator = described_class.new(proc {}, proc { 'tenant1' }) + it 'switches tenant when processor returns a tenant name' do + elevator = described_class.new(inner_app, ->(_req) { 'acme' }) + + expect(Apartment::Tenant).to(receive(:switch).with('acme').and_yield) + + elevator.call(Rack::MockRequest.env_for('http://example.com')) + end + + it 'does not switch when processor returns nil' do + elevator = described_class.new(inner_app, ->(_req) {}) - expect(Apartment::Tenant).to receive(:switch).with('tenant1') + expect(Apartment::Tenant).not_to(receive(:switch)) - elevator.call('HTTP_HOST' => 'foo.bar.com') + elevator.call(Rack::MockRequest.env_for('http://example.com')) end - it 'raises if parse_tenant_name not implemented' do - expect do - elevator.call('HTTP_HOST' => 'foo.bar.com') - end.to raise_error(RuntimeError) + it 'calls the inner app' do + elevator = described_class.new(inner_app, ->(_req) {}) + + status, = elevator.call(Rack::MockRequest.env_for('http://example.com')) + expect(status).to(eq(200)) end - it 'switches to the parsed db_name' do - elevator = MyElevator.new(proc {}) + it 'uses parse_tenant_name when no processor provided' do + subclass = Class.new(described_class) do + def parse_tenant_name(_request) + 'from_subclass' + end + end + + elevator = subclass.new(inner_app) + expect(Apartment::Tenant).to(receive(:switch).with('from_subclass').and_yield) - expect(Apartment::Tenant).to receive(:switch).with('tenant2') + elevator.call(Rack::MockRequest.env_for('http://example.com')) + end - elevator.call('HTTP_HOST' => 'foo.bar.com') + it 'absorbs keyword args without error' do + expect { described_class.new(inner_app, nil, some_option: 'value') }.not_to(raise_error) end - it 'calls the block implementation of `switch`' do - elevator = MyElevator.new(proc {}, proc { 'tenant2' }) + it 'propagates exceptions from parse_tenant_name' do + elevator = described_class.new(inner_app, ->(_req) { raise(Apartment::TenantNotFound, 'bad') }) - expect(Apartment::Tenant).to receive(:switch).with('tenant2').and_yield - elevator.call('HTTP_HOST' => 'foo.bar.com') + expect { elevator.call(Rack::MockRequest.env_for('http://example.com')) } + .to(raise_error(Apartment::TenantNotFound)) end - it 'does not call `switch` if no database given' do - app = proc {} - elevator = MyElevator.new(app, proc {}) + it 'propagates exceptions from Tenant.switch' do + elevator = described_class.new(inner_app, ->(_req) { 'acme' }) + + allow(Apartment::Tenant).to(receive(:switch).and_raise(Apartment::TenantNotFound, 'acme')) + + expect { elevator.call(Rack::MockRequest.env_for('http://example.com')) } + .to(raise_error(Apartment::TenantNotFound)) + end + end - expect(Apartment::Tenant).not_to receive(:switch) - expect(app).to receive :call + describe '#parse_tenant_name' do + it 'raises NotImplementedError by default' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('http://example.com')) - elevator.call('HTTP_HOST' => 'foo.bar.com') + expect { elevator.parse_tenant_name(request) } + .to(raise_error(NotImplementedError, /parse_tenant_name must be implemented/)) end end end diff --git a/spec/unit/elevators/header_spec.rb b/spec/unit/elevators/header_spec.rb new file mode 100644 index 00000000..ec2b8b05 --- /dev/null +++ b/spec/unit/elevators/header_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack' +require 'apartment/elevators/header' + +RSpec.describe(Apartment::Elevators::Header) do + let(:inner_app) { ->(_env) { [200, {}, ['ok']] } } + + def env_with_header(header_name, value) + rack_key = "HTTP_#{header_name.upcase.tr('-', '_')}" + Rack::MockRequest.env_for('http://example.com/', rack_key => value) + end + + describe '#parse_tenant_name' do + it 'extracts tenant from the default X-Tenant-Id header' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(env_with_header('X-Tenant-Id', 'acme')) + expect(elevator.parse_tenant_name(request)).to(eq('acme')) + end + + it 'extracts tenant from a custom header' do + elevator = described_class.new(inner_app, header: 'X-CampusESP-Tenant') + request = Rack::Request.new(env_with_header('X-CampusESP-Tenant', 'widgets')) + expect(elevator.parse_tenant_name(request)).to(eq('widgets')) + end + + it 'returns nil when header is missing' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('http://example.com/')) + expect(elevator.parse_tenant_name(request)).to(be_nil) + end + + it 'returns nil when header is empty string' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(env_with_header('X-Tenant-Id', '')) + expect(elevator.parse_tenant_name(request)).to(be_nil) + end + + it 'handles header names with mixed case' do + elevator = described_class.new(inner_app, header: 'x-tenant-id') + request = Rack::Request.new(env_with_header('X-Tenant-Id', 'acme')) + expect(elevator.parse_tenant_name(request)).to(eq('acme')) + end + + it 'accepts trusted: without affecting behavior' do + elevator = described_class.new(inner_app, trusted: true) + request = Rack::Request.new(env_with_header('X-Tenant-Id', 'acme')) + expect(elevator.parse_tenant_name(request)).to(eq('acme')) + end + end + + describe '#call' do + it 'switches tenant when header is present' do + elevator = described_class.new(inner_app) + expect(Apartment::Tenant).to(receive(:switch).with('acme').and_yield) + + elevator.call(env_with_header('X-Tenant-Id', 'acme')) + end + + it 'does not switch when header is absent' do + elevator = described_class.new(inner_app) + expect(Apartment::Tenant).not_to(receive(:switch)) + + elevator.call(Rack::MockRequest.env_for('http://example.com/')) + end + end + + describe '#raw_header' do + it 'returns the original header name' do + elevator = described_class.new(inner_app, header: 'X-CampusESP-Tenant') + expect(elevator.raw_header).to(eq('X-CampusESP-Tenant')) + end + + it 'returns the default header name' do + elevator = described_class.new(inner_app) + expect(elevator.raw_header).to(eq('X-Tenant-Id')) + end + + it 'is frozen' do + elevator = described_class.new(inner_app) + expect(elevator.raw_header).to(be_frozen) + end + end +end diff --git a/spec/unit/elevators/host_hash_spec.rb b/spec/unit/elevators/host_hash_spec.rb index 6fc2f72b..6369d7a4 100644 --- a/spec/unit/elevators/host_hash_spec.rb +++ b/spec/unit/elevators/host_hash_spec.rb @@ -1,33 +1,64 @@ # frozen_string_literal: true require 'spec_helper' +require 'rack' require 'apartment/elevators/host_hash' -describe Apartment::Elevators::HostHash do - subject(:elevator) { Apartment::Elevators::HostHash.new(proc {}, 'example.com' => 'example_tenant') } +RSpec.describe(Apartment::Elevators::HostHash) do + let(:inner_app) { ->(_env) { [200, {}, ['ok']] } } + let(:mapping) { { 'acme.com' => 'acme', 'widgets.io' => 'widgets' } } + + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end 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') + it 'returns the mapped tenant for a known host' do + elevator = described_class.new(inner_app, hash: mapping) + expect(elevator.parse_tenant_name(request_for('acme.com'))).to(eq('acme')) + end + + it 'raises TenantNotFound for an unknown host' do + elevator = described_class.new(inner_app, hash: mapping) + expect { elevator.parse_tenant_name(request_for('unknown.com')) } + .to(raise_error(Apartment::TenantNotFound, /unknown\.com/)) 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) + it 'sets the tenant attribute on TenantNotFound' do + elevator = described_class.new(inner_app, hash: mapping) + expect { elevator.parse_tenant_name(request_for('unknown.com')) } + .to(raise_error { |e| expect(e.tenant).to(eq('unknown.com')) }) 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) + it 'freezes the hash' do + elevator = described_class.new(inner_app, hash: mapping) + expect(elevator.instance_variable_get(:@hash)).to(be_frozen) + end + + it 'defaults to empty hash' do + elevator = described_class.new(inner_app) + expect { elevator.parse_tenant_name(request_for('anything.com')) } + .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') + it 'raises TenantNotFound for unknown host through full call stack' do + elevator = described_class.new(inner_app, hash: { 'known.com' => 'acme' }) + expect { elevator.call(Rack::MockRequest.env_for('http://unknown.com/')) } + .to(raise_error(Apartment::TenantNotFound, /unknown\.com/)) + end - elevator.call('HTTP_HOST' => 'example.com') + it 'does not call the inner app when host is unknown' do + called = false + app = lambda { |_env| + called = true + [200, {}, ['ok']] + } + elevator = described_class.new(app, hash: { 'known.com' => 'acme' }) + expect { elevator.call(Rack::MockRequest.env_for('http://unknown.com/')) } + .to(raise_error(Apartment::TenantNotFound)) + expect(called).to(be(false)) end end end diff --git a/spec/unit/elevators/host_spec.rb b/spec/unit/elevators/host_spec.rb index e0cb9c3c..d57f1b5e 100644 --- a/spec/unit/elevators/host_spec.rb +++ b/spec/unit/elevators/host_spec.rb @@ -1,89 +1,47 @@ # frozen_string_literal: true require 'spec_helper' +require 'rack' require 'apartment/elevators/host' -describe Apartment::Elevators::Host do - subject(:elevator) { described_class.new(proc {}) } +RSpec.describe(Apartment::Elevators::Host) do + let(:inner_app) { ->(_env) { [200, {}, ['ok']] } } + + def request_for(host) + Rack::Request.new(Rack::MockRequest.env_for("http://#{host}/")) + end describe '#parse_tenant_name' do - it 'should return nil when no host' do - request = ActionDispatch::Request.new('HTTP_HOST' => '') - expect(elevator.parse_tenant_name(request)).to be_nil + it 'returns the full hostname' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme.example.com')) end - context 'assuming no ignored_first_subdomains' do - before { allow(described_class).to receive(:ignored_first_subdomains).and_return([]) } - - context 'with 3 parts' do - it 'should return the whole host' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('foo.bar.com') - end - end - - context 'with 6 parts' do - it 'should return 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') - end - end + it 'strips ignored first subdomains' do + elevator = described_class.new(inner_app, ignored_first_subdomains: %w[www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(eq('example.com')) end - context 'assuming ignored_first_subdomains is set' do - before { allow(described_class).to receive(:ignored_first_subdomains).and_return(%w[www foo]) } - - context 'with 3 parts' do - it 'should return host without www' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'www.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('bar.com') - end - - it 'should return host without foo' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('bar.com') - end - end - - context 'with 6 parts' do - it 'should return host without www' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'www.one.two.three.foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('one.two.three.foo.bar.com') - end - - it 'should return host without www' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.one.two.three.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('one.two.three.bar.com') - end - end + it 'does not strip non-ignored subdomains' do + elevator = described_class.new(inner_app, ignored_first_subdomains: %w[www]) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme.example.com')) end - context 'assuming localhost' do - it 'should return localhost' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost') - expect(elevator.parse_tenant_name(request)).to eq('localhost') - end + it 'returns nil for blank host' do + elevator = described_class.new(inner_app) + request = Rack::Request.new(Rack::MockRequest.env_for('/')) + allow(request).to(receive(:host).and_return('')) + expect(elevator.parse_tenant_name(request)).to(be_nil) end - context 'assuming ip address' do - it 'should return 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') - 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') - elevator.call('HTTP_HOST' => 'foo.bar.com') + it 'coerces ignored_first_subdomains to strings' do + elevator = described_class.new(inner_app, ignored_first_subdomains: [:www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(eq('example.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') - elevator.call('HTTP_HOST' => 'foo.bar.com') + it 'freezes ignored_first_subdomains' do + elevator = described_class.new(inner_app, ignored_first_subdomains: %w[www]) + expect(elevator.instance_variable_get(:@ignored_first_subdomains)).to(be_frozen) end end end diff --git a/spec/unit/elevators/subdomain_spec.rb b/spec/unit/elevators/subdomain_spec.rb index 4c66ce67..b9d67388 100644 --- a/spec/unit/elevators/subdomain_spec.rb +++ b/spec/unit/elevators/subdomain_spec.rb @@ -1,77 +1,54 @@ # frozen_string_literal: true require 'spec_helper' +require 'rack' require 'apartment/elevators/subdomain' -describe Apartment::Elevators::Subdomain do - subject(:elevator) { described_class.new(proc {}) } +RSpec.describe(Apartment::Elevators::Subdomain) do + let(:inner_app) { ->(_env) { [200, {}, ['ok']] } } - describe '#parse_tenant_name' do - context 'assuming one tld' do - it 'should parse subdomain' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com') - expect(elevator.parse_tenant_name(request)).to eq('foo') - end - - it 'should return nil when no subdomain' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'bar.com') - expect(elevator.parse_tenant_name(request)).to be_nil - end - end + def env_for(host) + Rack::MockRequest.env_for("http://#{host}/") + end - context 'assuming two tlds' do - it 'should parse 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') - end + def request_for(host) + Rack::Request.new(env_for(host)) + end - it 'should return 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 - end + describe '#parse_tenant_name' do + it 'extracts subdomain from host' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.com'))).to(eq('acme')) end - context 'assuming two subdomains' do - it 'should parse 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') - end - - it 'should parse 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') - end + it 'returns nil for excluded subdomains' do + elevator = described_class.new(inner_app, excluded_subdomains: %w[www api]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(be_nil) end - context 'assuming localhost' do - it 'should return nil for localhost' do - request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost') - expect(elevator.parse_tenant_name(request)).to be_nil - end + it 'returns nil for bare domain (no subdomain)' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('example.com'))).to(be_nil) end - context 'assuming ip address' do - it 'should return 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 - end + it 'returns nil for IP addresses' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('127.0.0.1'))).to(be_nil) end - end - describe '#call' do - it 'switches to the proper tenant' do - expect(Apartment::Tenant).to receive(:switch).with('tenant1') - elevator.call('HTTP_HOST' => 'tenant1.example.com') + it 'handles international TLDs correctly' do + elevator = described_class.new(inner_app) + expect(elevator.parse_tenant_name(request_for('acme.example.co.uk'))).to(eq('acme')) end - it 'ignores excluded subdomains' do - described_class.excluded_subdomains = %w[foo] - - expect(Apartment::Tenant).not_to receive(:switch) - - elevator.call('HTTP_HOST' => 'foo.bar.com') + it 'coerces excluded_subdomains to strings' do + elevator = described_class.new(inner_app, excluded_subdomains: [:www]) + expect(elevator.parse_tenant_name(request_for('www.example.com'))).to(be_nil) + end - described_class.excluded_subdomains = nil + it 'freezes excluded_subdomains' do + elevator = described_class.new(inner_app, excluded_subdomains: %w[www]) + expect(elevator.instance_variable_get(:@excluded_subdomains)).to(be_frozen) end end end diff --git a/spec/unit/errors_spec.rb b/spec/unit/errors_spec.rb new file mode 100644 index 00000000..78836f40 --- /dev/null +++ b/spec/unit/errors_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe('Apartment error hierarchy') do + it 'defines ApartmentError as a StandardError' do + expect(Apartment::ApartmentError).to(be < StandardError) + end + + %i[TenantNotFound TenantExists AdapterNotFound ConfigurationError PoolExhausted SchemaLoadError + PendingMigrationError].each do |klass| + it "defines #{klass} as a subclass of ApartmentError" do + expect(Apartment.const_get(klass)).to(be < Apartment::ApartmentError) + end + end + + describe Apartment::TenantNotFound do + it 'includes the tenant name in the message when provided' do + error = described_class.new('acme') + expect(error.message).to(eq("Tenant 'acme' not found")) + end + + it 'exposes the tenant name via attr_reader' do + error = described_class.new('acme') + expect(error.tenant).to(eq('acme')) + end + + it 'uses a generic message when no tenant name is provided' do + error = described_class.new + expect(error.message).to(eq('Tenant not found')) + expect(error.tenant).to(be_nil) + end + end + + describe Apartment::TenantExists do + it 'includes the tenant name in the message when provided' do + error = described_class.new('acme') + expect(error.message).to(eq("Tenant 'acme' already exists")) + end + + it 'exposes the tenant name via attr_reader' do + error = described_class.new('acme') + expect(error.tenant).to(eq('acme')) + end + + it 'uses a generic message when no tenant name is provided' do + error = described_class.new + expect(error.message).to(eq('Tenant already exists')) + expect(error.tenant).to(be_nil) + end + end + + describe Apartment::PendingMigrationError do + it 'is a subclass of ApartmentError' do + expect(described_class).to(be < Apartment::ApartmentError) + end + + it 'includes the tenant name in the message when provided' do + error = described_class.new('acme') + expect(error.message).to(include("Tenant 'acme'")) + expect(error.message).to(include('pending migrations')) + end + + it 'includes apartment:migrate instruction in the message' do + error = described_class.new('acme') + expect(error.message).to(include('apartment:migrate')) + end + + it 'exposes the tenant name via attr_reader' do + error = described_class.new('acme') + expect(error.tenant).to(eq('acme')) + end + + it 'uses a generic message when no tenant name is provided' do + error = described_class.new + expect(error.message).to(eq('Tenant has pending migrations. Run apartment:migrate to update.')) + expect(error.tenant).to(be_nil) + end + end +end diff --git a/spec/unit/generator/install_generator_spec.rb b/spec/unit/generator/install_generator_spec.rb new file mode 100644 index 00000000..448fb8cc --- /dev/null +++ b/spec/unit/generator/install_generator_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'tmpdir' + +begin + require('rails/generators') + require('rails/generators/testing/behavior') + require('rails/generators/testing/assertions') + require_relative('../../../lib/generators/apartment/install/install_generator') +rescue LoadError + # Rails generators not available (base Gemfile without Rails). + # These specs only run under appraisal. +end + +return unless defined?(Rails::Generators) + +RSpec.describe(Apartment::InstallGenerator) do + include FileUtils + + let(:destination) { Dir.mktmpdir } + + before do + described_class.start([], destination_root: destination, quiet: true) + end + + after do + rm_rf(destination) + end + + describe 'initializer' do + let(:initializer_path) { File.join(destination, 'config', 'initializers', 'apartment.rb') } + + it 'creates the initializer file' do + expect(File.exist?(initializer_path)).to(be(true)) + end + + it 'contains tenant_strategy' do + content = File.read(initializer_path) + expect(content).to(include('config.tenant_strategy')) + end + + it 'contains tenants_provider' do + content = File.read(initializer_path) + expect(content).to(include('config.tenants_provider')) + end + + it 'does not contain v3 references' do + content = File.read(initializer_path) + expect(content).not_to(include('tenant_names')) + expect(content).not_to(include('use_schemas')) + expect(content).not_to(include('use_sql')) + expect(content).not_to(include('prepend_environment')) + expect(content).not_to(include('pg_excluded_names')) + expect(content).not_to(include('middleware.use')) + end + + it 'does not require elevator files' do + content = File.read(initializer_path) + expect(content).not_to(include("require 'apartment/elevators")) + end + + it 'includes RBAC options in comments' do + content = File.read(initializer_path) + expect(content).to(include('migration_role')) + expect(content).to(include('app_role')) + end + + it 'includes elevator options in comments' do + content = File.read(initializer_path) + expect(content).to(include('config.elevator')) + expect(content).to(include('elevator_options')) + end + end + + describe 'binstub' do + let(:binstub_path) { File.join(destination, 'bin', 'apartment') } + + it 'creates the binstub file' do + expect(File.exist?(binstub_path)).to(be(true)) + end + + it 'is executable' do + expect(File.executable?(binstub_path)).to(be(true)) + end + + it 'requires config/environment' do + content = File.read(binstub_path) + expect(content).to(include("require_relative '../config/environment'")) + end + + it 'requires apartment/cli' do + content = File.read(binstub_path) + expect(content).to(include("require 'apartment/cli'")) + end + + it 'starts CLI' do + content = File.read(binstub_path) + expect(content).to(include('Apartment::CLI.start')) + end + end +end diff --git a/spec/unit/instrumentation_spec.rb b/spec/unit/instrumentation_spec.rb new file mode 100644 index 00000000..3d7edd31 --- /dev/null +++ b/spec/unit/instrumentation_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Apartment::Instrumentation) do + describe '.instrument' do + it 'publishes switch.apartment events' do + events = [] + ActiveSupport::Notifications.subscribe('switch.apartment') { |event| events << event } + + described_class.instrument(:switch, tenant: 'acme', previous_tenant: 'public') + + expect(events.size).to(eq(1)) + expect(events.first.payload).to(include(tenant: 'acme', previous_tenant: 'public')) + ensure + ActiveSupport::Notifications.unsubscribe('switch.apartment') + end + + it 'publishes create.apartment events' do + events = [] + ActiveSupport::Notifications.subscribe('create.apartment') { |event| events << event } + + described_class.instrument(:create, tenant: 'new_tenant') + + expect(events.size).to(eq(1)) + expect(events.first.payload[:tenant]).to(eq('new_tenant')) + ensure + ActiveSupport::Notifications.unsubscribe('create.apartment') + end + + it 'forwards blocks and returns block result' do + events = [] + ActiveSupport::Notifications.subscribe('switch.apartment') { |event| events << event } + + result = described_class.instrument(:switch, tenant: 'acme') { 'block_result' } + + expect(result).to(eq('block_result')) + expect(events.size).to(eq(1)) + ensure + ActiveSupport::Notifications.unsubscribe('switch.apartment') + end + + it 'publishes evict.apartment events' do + events = [] + ActiveSupport::Notifications.subscribe('evict.apartment') { |event| events << event } + + described_class.instrument(:evict, tenant: 'old', reason: :idle) + + expect(events.first.payload).to(include(tenant: 'old', reason: :idle)) + ensure + ActiveSupport::Notifications.unsubscribe('evict.apartment') + end + end +end diff --git a/spec/unit/migrator_spec.rb b/spec/unit/migrator_spec.rb index 8cbcb9e2..ea15e4c4 100644 --- a/spec/unit/migrator_spec.rb +++ b/spec/unit/migrator_spec.rb @@ -1,78 +1,481 @@ # frozen_string_literal: true require 'spec_helper' -require 'apartment/migrator' +require_relative '../../lib/apartment/migrator' -describe Apartment::Migrator do - let(:tenant) { Apartment::Test.next_db } +RSpec.describe(Apartment::Migrator::Result) do + subject(:result) do + described_class.new( + tenant: 'acme', + status: :success, + duration: 1.23, + error: nil, + versions_run: [20_260_401_000_000, 20_260_402_000_000] + ) + end + + it 'is frozen (Data.define)' do + expect(result).to(be_frozen) + end + + it 'exposes all attributes' do + expect(result.tenant).to(eq('acme')) + expect(result.status).to(eq(:success)) + expect(result.duration).to(eq(1.23)) + expect(result.error).to(be_nil) + expect(result.versions_run).to(eq([20_260_401_000_000, 20_260_402_000_000])) + end +end + +RSpec.describe(Apartment::Migrator::MigrationRun) do + let(:success_result) do + Apartment::Migrator::Result.new( + tenant: 'acme', status: :success, duration: 1.0, error: nil, versions_run: [1] + ) + end + let(:failed_result) do + Apartment::Migrator::Result.new( + tenant: 'broken', status: :failed, duration: 0.5, + error: StandardError.new('boom'), versions_run: [] + ) + end + let(:skipped_result) do + Apartment::Migrator::Result.new( + tenant: 'current', status: :skipped, duration: 0.01, error: nil, versions_run: [] + ) + end + + describe 'with mixed results' do + subject(:run) do + described_class.new( + results: [success_result, failed_result, skipped_result], + total_duration: 2.5, + threads: 4 + ) + end - # Don't need a real switch here, just testing behaviour - before { allow(Apartment::Tenant.adapter).to receive(:connect_to_new) } + describe '#succeeded' do + it 'returns only success results' do + expect(run.succeeded.map(&:tenant)).to(eq(['acme'])) + end + end + + describe '#failed' do + it 'returns only failed results' do + expect(run.failed.map(&:tenant)).to(eq(['broken'])) + end + end + + describe '#skipped' do + it 'returns only skipped results' do + expect(run.skipped.map(&:tenant)).to(eq(['current'])) + end + end + + describe '#success?' do + it 'returns false when there are failures' do + expect(run.success?).to(be(false)) + end + end + + describe '#summary' do + it 'includes counts, timing, and error details' do + summary = run.summary + expect(summary).to(include('3 tenants')) + expect(summary).to(include('2.5s')) + expect(summary).to(include('1 succeeded')) + expect(summary).to(include('1 failed')) + expect(summary).to(include('1 skipped')) + expect(summary).to(include('broken')) + expect(summary).to(include('StandardError')) + expect(summary).to(include('boom')) + end + end + end + + describe 'with all success' do + subject(:run) do + described_class.new( + results: [success_result, skipped_result], + total_duration: 1.0, + threads: 2 + ) + end + + it '#success? returns true' do + expect(run.success?).to(be(true)) + end + + it '#summary omits failed section' do + summary = run.summary + expect(summary).to(include('2 tenants')) + expect(summary).to(include('1 succeeded')) + expect(summary).to(include('1 skipped')) + expect(summary).not_to(include('failed')) + end + end +end + +RSpec.describe(Apartment::Migrator) do + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[acme beta] } + c.default_tenant = 'public' + end + end + + describe '#initialize' do + it 'defaults to 0 threads and nil version' do + migrator = described_class.new + expect(migrator.instance_variable_get(:@threads)).to(eq(0)) + expect(migrator.instance_variable_get(:@version)).to(be_nil) + end + + it 'accepts threads and version parameters' do + migrator = described_class.new(threads: 8, version: 20_260_401_000_000) + expect(migrator.instance_variable_get(:@threads)).to(eq(8)) + expect(migrator.instance_variable_get(:@version)).to(eq(20_260_401_000_000)) + end + end + + describe '#run' do + let(:migrator) { described_class.new(threads: 0) } + let(:mock_migration_context) { instance_double('ActiveRecord::MigrationContext') } + let(:mock_pool) { instance_double('ActiveRecord::ConnectionAdapters::ConnectionPool') } + let(:mock_connection) { double('connection') } - context 'with ActiveRecord below 5.2.0', skip: ActiveRecord.version >= Gem::Version.new('5.2.0') do before do - allow(ActiveRecord::Migrator).to receive(:migrations_paths) { %w[spec/dummy/db/migrate] } - allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { true } + allow(ActiveRecord::Base).to(receive_messages(connection_pool: mock_pool, lease_connection: mock_connection)) + allow(mock_connection).to(receive(:instance_variable_get).and_return(true)) + allow(mock_connection).to(receive(:instance_variable_set)) + allow(mock_pool).to(receive(:migration_context).and_return(mock_migration_context)) + allow(mock_migration_context).to(receive_messages(needs_migration?: true, migrate: [])) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(Apartment::Tenant).to(receive(:switch)) { |_tenant, &block| block.call } + end + + it 'returns a MigrationRun' do + result = migrator.run + expect(result).to(be_a(Apartment::Migrator::MigrationRun)) end - describe '::migrate' do - it 'switches and migrates' do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect(ActiveRecord::Migrator).to receive(:migrate) + it 'includes primary and tenant results' do + result = migrator.run + tenants = result.results.map(&:tenant) + expect(tenants).to(include('public')) + expect(tenants).to(include('acme')) + expect(tenants).to(include('beta')) + end + + it 'primary result comes first' do + result = migrator.run + expect(result.results.first.tenant).to(eq('public')) + end + + it 'returns :skipped for tenants with no pending migrations' do + allow(mock_migration_context).to(receive(:needs_migration?).and_return(false)) + result = migrator.run + expect(result.results.map(&:status)).to(all(eq(:skipped))) + end + + it 'captures errors without halting the run when a tenant fails' do + call_count = 0 + allow(mock_migration_context).to(receive(:migrate)) do + call_count += 1 + raise(StandardError, 'boom') if call_count == 2 - Apartment::Migrator.migrate(tenant) + [] end + result = migrator.run + expect(result.failed.size).to(be >= 1) + expect(result.results.size).to(eq(3)) end - describe '::run' do - it 'switches and runs' do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect(ActiveRecord::Migrator).to receive(:run).with(:up, anything, 1234) + it 'aborts and returns only the primary result when primary migration fails' do + allow(mock_migration_context).to(receive(:migrate).and_raise(StandardError, 'db down')) + result = migrator.run + expect(result.results.size).to(eq(1)) + expect(result.results.first.status).to(eq(:failed)) + expect(result).not_to(be_success) + end - Apartment::Migrator.run(:up, tenant, 1234) + it 'instruments each migration' do + expect(Apartment::Instrumentation).to(receive(:instrument) + .with(:migrate_tenant, hash_including(:tenant)).at_least(3).times) + migrator.run + end + + it 'switches tenant for each tenant migration' do + migrator.run + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + expect(Apartment::Tenant).to(have_received(:switch).with('beta')) + end + + it 'disables advisory locks for tenant migrations and restores afterward' do + migrator.run + expect(mock_connection).to(have_received(:instance_variable_set) + .with(:@advisory_locks_enabled, false).at_least(:twice)) + expect(mock_connection).to(have_received(:instance_variable_set) + .with(:@advisory_locks_enabled, true).at_least(:twice)) + end + + it 'handles empty tenant list' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' end + result = migrator.run + expect(result.results.size).to(eq(1)) end - describe '::rollback' do - it 'switches and rolls back' do - expect(Apartment::Tenant).to receive(:switch).with(tenant).and_call_original - expect(ActiveRecord::Migrator).to receive(:rollback).with(anything, 2) + context 'with target version' do + let(:migrator) { described_class.new(threads: 0, version: 20_260_401_000_000) } + + it 'passes version to migrate' do + expect(mock_migration_context).to(receive(:migrate).with(20_260_401_000_000).at_least(:once).and_return([])) + migrator.run + end - Apartment::Migrator.rollback(tenant, 2) + it 'does not skip based on needs_migration? when version is set' do + allow(mock_migration_context).to(receive(:needs_migration?).and_return(false)) + result = migrator.run + # With a version target, migrate is called even if needs_migration? is false + # (could be a rollback to an older version) + expect(result.results.map(&:status)).to(all(eq(:success))) end end end - context 'with ActiveRecord above or equal to 5.2.0', skip: ActiveRecord.version < Gem::Version.new('5.2.0') do + describe '#migrate_tenant Current.migrating lifecycle' do + let(:mock_pool) { double('pool', migration_context: double(needs_migration?: false)) } + + it 'sets Current.migrating = true before Tenant.switch' do + migrating_value_during_switch = nil + allow(Apartment::Tenant).to(receive(:switch)) do |&block| + migrating_value_during_switch = Apartment::Current.migrating + block&.call + end + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(mock_pool)) + + migrator = described_class.new + migrator.send(:migrate_tenant, 'acme') + + expect(migrating_value_during_switch).to(be(true)) + end + + it 'clears Current.migrating after migrate_tenant completes' do + allow(Apartment::Tenant).to(receive(:switch).and_yield) + allow(ActiveRecord::Base).to(receive(:connection_pool).and_return(mock_pool)) + + migrator = described_class.new + migrator.send(:migrate_tenant, 'acme') + + expect(Apartment::Current.migrating).to(be_falsey) + end + + it 'clears Current.migrating even on error' do + allow(Apartment::Tenant).to(receive(:switch).and_raise(StandardError, 'boom')) + + migrator = described_class.new + migrator.send(:migrate_tenant, 'acme') + + expect(Apartment::Current.migrating).to(be_falsey) + end + end + + describe '.with_migration_role' do + it 'yields without connected_to when migration_role is nil' do + expect(ActiveRecord::Base).not_to(receive(:connected_to)) + described_class.with_migration_role { 'result' } + end + + it 'wraps in connected_to when migration_role is set' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.migration_role = :db_manager + end + expect(ActiveRecord::Base).to(receive(:connected_to).with(role: :db_manager).and_yield) + described_class.with_migration_role { 'result' } + end + + it 'is accessible as a private instance method (delegates to class method)' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.migration_role = :db_manager + end + migrator = described_class.new + expect(ActiveRecord::Base).to(receive(:connected_to).with(role: :db_manager).and_yield) + migrator.send(:with_migration_role) { 'result' } + end + end + + describe '#evict_migration_pools' do + it 'evicts pools by migration_role and deregisters shards' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.migration_role = :db_manager + end + pool_manager = instance_double(Apartment::PoolManager) + allow(Apartment).to(receive(:pool_manager).and_return(pool_manager)) + allow(pool_manager).to(receive(:evict_by_role).with(:db_manager).and_return([['acme:db_manager', double]])) + allow(Apartment).to(receive(:deregister_shard)) + + migrator = described_class.new + migrator.send(:evict_migration_pools) + + expect(pool_manager).to(have_received(:evict_by_role).with(:db_manager)) + expect(Apartment).to(have_received(:deregister_shard).with('acme:db_manager')) + end + + it 'no-ops when migration_role is nil' do + migrator = described_class.new + expect(Apartment).not_to(receive(:pool_manager)) + migrator.send(:evict_migration_pools) + end + end + + describe '#migrate_one' do + let(:migrator) { described_class.new } + let(:mock_migration_context) { instance_double('ActiveRecord::MigrationContext') } + let(:mock_pool) { instance_double('ActiveRecord::ConnectionAdapters::ConnectionPool') } + let(:mock_connection) { double('connection') } + before do - allow(Apartment::Migrator).to receive(:activerecord_below_5_2?) { false } + allow(ActiveRecord::Base).to(receive_messages(connection_pool: mock_pool, lease_connection: mock_connection)) + allow(mock_connection).to(receive(:instance_variable_get).and_return(true)) + allow(mock_connection).to(receive(:instance_variable_set)) + allow(mock_pool).to(receive(:migration_context).and_return(mock_migration_context)) + allow(mock_migration_context).to(receive_messages(needs_migration?: true, migrate: [])) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(Apartment::Tenant).to(receive(:switch)) { |_tenant, &block| block.call } + end + + it 'returns a single Result for the given tenant' do + result = migrator.migrate_one('acme') + expect(result).to(be_a(Apartment::Migrator::Result)) + expect(result.tenant).to(eq('acme')) + expect(result.status).to(eq(:success)) + end + + it 'switches to the given tenant' do + migrator.migrate_one('acme') + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + end + + it 'sets Current.migrating during execution' do + migrating_during = nil + allow(Apartment::Tenant).to(receive(:switch)) do |&block| + migrating_during = Apartment::Current.migrating + block&.call + end + migrator.migrate_one('acme') + expect(migrating_during).to(be(true)) + end + + it 'clears Current.migrating after completion' do + migrator.migrate_one('acme') + expect(Apartment::Current.migrating).to(be_falsey) + end + + it 'disables advisory locks' do + migrator.migrate_one('acme') + expect(mock_connection).to(have_received(:instance_variable_set) + .with(:@advisory_locks_enabled, false)) + end + + it 'instruments the migration' do + migrator.migrate_one('acme') + expect(Apartment::Instrumentation).to(have_received(:instrument) + .with(:migrate_tenant, hash_including(tenant: 'acme'))) + end + + it 'returns :skipped when no pending migrations' do + allow(mock_migration_context).to(receive(:needs_migration?).and_return(false)) + result = migrator.migrate_one('acme') + expect(result.status).to(eq(:skipped)) + end + + it 'captures errors and returns :failed' do + allow(mock_migration_context).to(receive(:migrate).and_raise(StandardError, 'boom')) + result = migrator.migrate_one('acme') + expect(result.status).to(eq(:failed)) + expect(result.error.message).to(eq('boom')) end - 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) + it 'respects version parameter' do + migrator = described_class.new(version: 20_260_401_000_000) + expect(mock_migration_context).to(receive(:migrate).with(20_260_401_000_000).and_return([])) + migrator.migrate_one('acme') + end - Apartment::Migrator.migrate(tenant) + it 'calls evict_migration_pools in ensure' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.migration_role = :db_manager end + pool_manager = instance_double(Apartment::PoolManager) + allow(Apartment).to(receive(:pool_manager).and_return(pool_manager)) + allow(pool_manager).to(receive(:evict_by_role).and_return([])) + + migrator = described_class.new + migrator.migrate_one('acme') + + expect(pool_manager).to(have_received(:evict_by_role).with(:db_manager)) 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) + describe '#run with threads > 0' do + let(:migrator) { described_class.new(threads: 4) } + let(:mock_migration_context) { instance_double('ActiveRecord::MigrationContext') } + let(:mock_pool) { instance_double('ActiveRecord::ConnectionAdapters::ConnectionPool') } + let(:mock_connection) { double('connection') } - Apartment::Migrator.run(:up, tenant, 1234) + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { (1..8).map { |i| "tenant_#{i}" } } + c.default_tenant = 'public' end + + allow(ActiveRecord::Base).to(receive_messages(connection_pool: mock_pool, lease_connection: mock_connection)) + allow(mock_connection).to(receive(:instance_variable_get).and_return(true)) + allow(mock_connection).to(receive(:instance_variable_set)) + allow(mock_pool).to(receive(:migration_context).and_return(mock_migration_context)) + allow(mock_migration_context).to(receive_messages(needs_migration?: true, migrate: [])) + allow(Apartment::Instrumentation).to(receive(:instrument)) + allow(Apartment::Tenant).to(receive(:switch)) { |_tenant, &block| block.call } + end + + it 'migrates all tenants plus primary' do + result = migrator.run + expect(result.results.size).to(eq(9)) + end + + it 'records thread count in MigrationRun' do + result = migrator.run + expect(result.threads).to(eq(4)) 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) + it 'captures tenant errors without halting parallel run' do + call_count = Concurrent::AtomicFixnum.new(0) + allow(mock_migration_context).to(receive(:migrate)) do + raise(StandardError, 'boom') if call_count.increment == 3 - Apartment::Migrator.rollback(tenant, 2) + [] end + result = migrator.run + expect(result.failed.size).to(eq(1)) + expect(result.results.size).to(eq(9)) end end end diff --git a/spec/unit/patches/connection_handling_spec.rb b/spec/unit/patches/connection_handling_spec.rb new file mode 100644 index 00000000..24eba9c9 --- /dev/null +++ b/spec/unit/patches/connection_handling_spec.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# This spec requires real ActiveRecord + sqlite3 gem (not the stub in apartment_spec.rb). +# Run via any sqlite3 appraisal, e.g.: bundle exec appraisal rails-8.1-sqlite3 rspec spec/unit/patches/ +# Skips gracefully when sqlite3 is not available or when the AR stub from +# apartment_spec.rb loaded first (randomized suite order). +REAL_AR_AVAILABLE = begin + require('active_record') + # The stub in apartment_spec.rb defines AR::Base without establish_connection. + # If that loaded first, real AR's require is a partial no-op. Detect this. + if ActiveRecord::Base.respond_to?(:establish_connection) + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + require_relative('../../../lib/apartment/patches/connection_handling') + ActiveRecord::Base.singleton_class.prepend(Apartment::Patches::ConnectionHandling) + true + else + warn '[connection_handling_spec] Skipping: AR stub loaded (no establish_connection)' + false + end +rescue LoadError => e + warn "[connection_handling_spec] Skipping: #{e.message}" + false +end + +unless REAL_AR_AVAILABLE + module Apartment + module Patches + module ConnectionHandling; end + end + end +end + +RSpec.describe(Apartment::Patches::ConnectionHandling) do + before do + skip 'requires real ActiveRecord with sqlite3 gem (run via appraisal)' unless REAL_AR_AVAILABLE + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { %w[acme widgets] } + config.default_tenant = 'public' + config.check_pending_migrations = false + end + Apartment.adapter = mock_adapter + end + + let(:mock_adapter) do + double('AbstractAdapter', + validated_connection_config: { 'adapter' => 'sqlite3', 'database' => ':memory:' }) + end + + # Capture the default pool with no tenant set, for comparison in tests. + let(:default_pool) do + Apartment::Current.tenant = nil + ActiveRecord::Base.connection_pool + end + + describe '#connection_pool' do + context 'when tenant is nil' do + it 'returns the default pool' do + Apartment::Current.tenant = nil + expect(ActiveRecord::Base.connection_pool).to(equal(default_pool)) + end + end + + context 'when tenant equals the default tenant' do + it 'returns the default pool' do + Apartment::Current.tenant = 'public' + expect(ActiveRecord::Base.connection_pool).to(equal(default_pool)) + end + end + + context 'when an active tenant is set' do + it 'returns a different pool from the default' do + Apartment::Current.tenant = 'acme' + tenant_pool = ActiveRecord::Base.connection_pool + expect(tenant_pool).not_to(equal(default_pool)) + end + + it 'returns an ActiveRecord::ConnectionAdapters::ConnectionPool' do + Apartment::Current.tenant = 'acme' + expect(ActiveRecord::Base.connection_pool).to( + be_a(ActiveRecord::ConnectionAdapters::ConnectionPool) + ) + end + end + + context 'caching' do + it 'returns the same pool on repeated calls for the same tenant' do + Apartment::Current.tenant = 'acme' + pool1 = ActiveRecord::Base.connection_pool + pool2 = ActiveRecord::Base.connection_pool + expect(pool1).to(equal(pool2)) + end + + it 'returns different pools for different tenants' do + Apartment::Current.tenant = 'acme' + acme_pool = ActiveRecord::Base.connection_pool + + Apartment::Current.tenant = 'widgets' + widgets_pool = ActiveRecord::Base.connection_pool + + expect(acme_pool).not_to(equal(widgets_pool)) + end + end + + context 'AR ConnectionHandler registration' do + it 'registers the pool under the namespaced shard key' do + Apartment::Current.tenant = 'acme' + ActiveRecord::Base.connection_pool + + role = ActiveRecord::Base.current_role + shard_key = :"#{Apartment.config.shard_key_prefix}_acme:#{role}" + registered = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: role, + shard: shard_key + ) + expect(registered).not_to(be_nil) + end + + it 'stores the correct adapter in db_config' do + Apartment::Current.tenant = 'acme' + pool = ActiveRecord::Base.connection_pool + expect(pool.db_config.adapter).to(eq('sqlite3')) + end + + it 'deregister_all_tenant_pools removes AR handler entries' do + # Create pools for two tenants + Apartment::Current.tenant = 'acme' + ActiveRecord::Base.connection_pool + Apartment::Current.tenant = 'widgets' + ActiveRecord::Base.connection_pool + + prefix = Apartment.config.shard_key_prefix + + role = ActiveRecord::Base.current_role + + # Verify they exist + %w[acme widgets].each do |t| + expect(ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', role: role, shard: :"#{prefix}_#{t}:#{role}" + )).not_to(be_nil) + end + + # Deregister all + Apartment.send(:deregister_all_tenant_pools) + + # Verify they're gone + %w[acme widgets].each do |t| + expect(ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', role: role, shard: :"#{prefix}_#{t}:#{role}" + )).to(be_nil) + end + end + end + + context 'pool usability' do + it 'can execute a real query against the tenant pool' do + Apartment::Current.tenant = 'acme' + pool = ActiveRecord::Base.connection_pool + result = pool.with_connection { |conn| conn.execute('SELECT 1 AS n') } + expect(result.first['n']).to(eq(1)) + end + end + + context 'PoolManager tracking' do + it 'registers the pool in PoolManager' do + Apartment::Current.tenant = 'acme' + ActiveRecord::Base.connection_pool + role = ActiveRecord::Base.current_role + expect(Apartment.pool_manager.tracked?("acme:#{role}")).to(be(true)) + end + + it 'does not register the default tenant in PoolManager' do + Apartment::Current.tenant = nil + ActiveRecord::Base.connection_pool + expect(Apartment.pool_manager.tracked?('public')).to(be(false)) + end + end + + context 'when pool_manager is nil (unconfigured)' do + it 'returns the default pool without raising' do + Apartment.clear_config + Apartment::Current.tenant = 'acme' + expect { ActiveRecord::Base.connection_pool }.not_to(raise_error) + end + end + + describe 'Apartment.activate!' do + it 'prepends ConnectionHandling on ActiveRecord::Base singleton class' do + # activate! is idempotent (prepend is a no-op if already prepended) + Apartment.activate! + expect(ActiveRecord::Base.singleton_class.ancestors).to(include(described_class)) + end + end + + context 'hyphenated tenant name' do + let(:mock_adapter_hyph) do + double('AbstractAdapter', + validated_connection_config: { 'adapter' => 'sqlite3', 'database' => ':memory:' }) + end + + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { ['my-tenant'] } + config.default_tenant = 'public' + config.check_pending_migrations = false + end + Apartment.adapter = mock_adapter_hyph + end + + it 'registers pool under the hyphenated shard key' do + Apartment::Current.tenant = 'my-tenant' + pool = ActiveRecord::Base.connection_pool + expect(pool).not_to(be_nil) + expect(pool).to(be_a(ActiveRecord::ConnectionAdapters::ConnectionPool)) + end + + it 'pool is tracked in PoolManager under the hyphenated key' do + Apartment::Current.tenant = 'my-tenant' + ActiveRecord::Base.connection_pool + role = ActiveRecord::Base.current_role + expect(Apartment.pool_manager.tracked?("my-tenant:#{role}")).to(be(true)) + end + end + + context 'role interaction' do + it 'registers pool under the default role and namespaced shard key' do + Apartment::Current.tenant = 'acme' + ActiveRecord::Base.connection_pool + + role = ActiveRecord::Base.current_role + shard_key = :"#{Apartment.config.shard_key_prefix}_acme:#{role}" + pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: role, + shard: shard_key + ) + expect(pool).not_to(be_nil) + expect(pool).to(be_a(ActiveRecord::ConnectionAdapters::ConnectionPool)) + end + end + + context 'role-aware pool keys' do + it 'includes current_role in the pool key' do + Apartment::Current.tenant = 'acme' + ActiveRecord::Base.connection_pool + + # The pool key must be "tenant:role" — verify the role portion is present + # (real role name; stubbing an undefined AR role breaks super resolution) + role = ActiveRecord::Base.current_role + expect(Apartment.pool_manager.tracked?("acme:#{role}")).to(be(true)) + # Confirm the key contains a colon-separated role, not just the tenant name + keys = Apartment.pool_manager.instance_variable_get(:@pools).keys + acme_key = keys.find { |k| k.start_with?('acme:') } + expect(acme_key).to(match(/\Aacme:.+\z/)) + end + end + + context 'pending migration check' do + it 'is suppressed when check_pending_migrations is false' do + Apartment::Current.tenant = 'acme' + expect { ActiveRecord::Base.connection_pool }.not_to(raise_error) + end + end + + context 'pinned model bypass' do + before do + require_relative('../../../lib/apartment/concerns/model') + end + + it 'returns the default pool for a pinned AR::Base subclass when tenant is set' do + pinned_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedBypassModel', pinned_class) + pinned_class.pin_tenant + + Apartment::Current.tenant = 'acme' + # Pinned class must use super (default pool), not the tenant pool + expect(pinned_class.connection_pool).to(equal(default_pool)) + end + + it 'does not bypass for ActiveRecord::Base itself' do + Apartment::Current.tenant = 'acme' + tenant_pool = ActiveRecord::Base.connection_pool + expect(tenant_pool).not_to(equal(default_pool)) + end + + it 'bypasses for STI subclass of a pinned model' do + parent = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedParentBypass', parent) + parent.pin_tenant + + child = Class.new(parent) + stub_const('PinnedChildBypass', child) + + Apartment::Current.tenant = 'acme' + expect(child.connection_pool).to(equal(default_pool)) + end + + it 'does not bypass for an unpinned AR::Base subclass' do + unpinned = Class.new(ActiveRecord::Base) + stub_const('UnpinnedWidget', unpinned) + + Apartment::Current.tenant = 'acme' + expect(unpinned.connection_pool).not_to(equal(default_pool)) + end + end + + context 'custom shard_key_prefix' do + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { %w[acme] } + config.default_tenant = 'public' + config.shard_key_prefix = 'myapp' + config.check_pending_migrations = false + end + Apartment.adapter = mock_adapter + end + + it 'uses the custom prefix for shard keys' do + Apartment::Current.tenant = 'acme' + ActiveRecord::Base.connection_pool + + role = ActiveRecord::Base.current_role + registered = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: role, + shard: :"myapp_acme:#{role}" + ) + expect(registered).not_to(be_nil) + end + + it 'does not register under the default prefix' do + Apartment::Current.tenant = 'acme' + ActiveRecord::Base.connection_pool + + role = ActiveRecord::Base.current_role + registered = ActiveRecord::Base.connection_handler.retrieve_connection_pool( + 'ActiveRecord::Base', + role: role, + shard: :"apartment_acme:#{role}" + ) + expect(registered).to(be_nil) + end + end + end + + describe 'pinned_model? registry check' do + before do + require_relative('../../../lib/apartment/concerns/model') + end + + it 'returns true for a pinned model' do + pinned_class = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedGlobal', pinned_class) + pinned_class.pin_tenant + + expect(Apartment.pinned_model?(PinnedGlobal)).to(be(true)) + end + + it 'returns true for STI subclass of a pinned model' do + parent = Class.new(ActiveRecord::Base) do + include Apartment::Model + end + stub_const('PinnedParentModel', parent) + parent.pin_tenant + + child = Class.new(parent) + stub_const('PinnedChildModel', child) + + expect(Apartment.pinned_model?(PinnedChildModel)).to(be(true)) + end + + it 'returns false for normal tenant models' do + tenant_class = Class.new(ActiveRecord::Base) + stub_const('TenantWidget', tenant_class) + + expect(Apartment.pinned_model?(TenantWidget)).to(be(false)) + end + end +end diff --git a/spec/unit/phase1_integration_spec.rb b/spec/unit/phase1_integration_spec.rb new file mode 100644 index 00000000..25f0589c --- /dev/null +++ b/spec/unit/phase1_integration_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe('Phase 1 integration') do + after { Apartment.clear_config } + + it 'configure -> pool_manager -> current -> reaper work together' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { %w[acme globex] } + config.default_tenant = 'public' + config.tenant_pool_size = 5 + config.pool_idle_timeout = 1 + end + + expect(Apartment.config.tenant_strategy).to(eq(:schema)) + expect(Apartment.pool_manager).to(be_a(Apartment::PoolManager)) + + # Simulate tenant switching via Current + Apartment::Current.tenant = 'acme' + expect(Apartment::Current.tenant).to(eq('acme')) + + # Pool manager tracks tenant pools + pool = Apartment.pool_manager.fetch_or_create('acme') { 'fake_pool' } + expect(pool).to(eq('fake_pool')) + + # Stats work + stats = Apartment.pool_manager.stats + expect(stats[:total_pools]).to(eq(1)) + expect(stats[:tenants]).to(eq(['acme'])) + + # Current resets cleanly + Apartment::Current.reset + expect(Apartment::Current.tenant).to(be_nil) + end + + it 'raises ConfigurationError when tenant_strategy missing' do + expect do + Apartment.configure do |config| + config.tenants_provider = -> { [] } + end + end.to(raise_error(Apartment::ConfigurationError, /tenant_strategy/)) + end + + it 'raises ConfigurationError when tenants_provider missing' do + expect do + Apartment.configure do |config| + config.tenant_strategy = :schema + end + end.to(raise_error(Apartment::ConfigurationError, /tenants_provider/)) + end + + it 'raises TenantNotFound with accessible tenant name' do + error = Apartment::TenantNotFound.new('missing') + expect(error.message).to(eq("Tenant 'missing' not found")) + expect(error.tenant).to(eq('missing')) + expect(error).to(be_a(Apartment::ApartmentError)) + end +end diff --git a/spec/unit/pool_manager_spec.rb b/spec/unit/pool_manager_spec.rb new file mode 100644 index 00000000..774bbd37 --- /dev/null +++ b/spec/unit/pool_manager_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Apartment::PoolManager) do + subject(:manager) { described_class.new } + + describe '#fetch_or_create' do + it 'creates and caches a new entry' do + result = manager.fetch_or_create('tenant_a') { 'pool_a' } + expect(result).to(eq('pool_a')) + end + + it 'returns cached entry on subsequent calls' do + call_count = 0 + 2.times do + manager.fetch_or_create('tenant_a') do + call_count += 1 + "pool_#{call_count}" + end + end + expect(manager.fetch_or_create('tenant_a') { 'new' }).to(eq('pool_1')) + end + + it 'tracks seconds_idle for the pool' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + stats = manager.stats_for('tenant_a') + expect(stats[:seconds_idle]).to(be_within(1).of(0)) + end + end + + describe '#fetch_or_create when block raises' do + it 'does not store a value and re-raises' do + expect { manager.fetch_or_create('bad') { raise('pool creation failed') } } + .to(raise_error(RuntimeError, 'pool creation failed')) + expect(manager.tracked?('bad')).to(be(false)) + end + end + + describe '#get' do + it 'returns the pool for an existing tenant' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + expect(manager.get('tenant_a')).to(eq('pool_a')) + end + + it 'returns nil for unknown tenants' do + expect(manager.get('unknown')).to(be_nil) + end + + it 'resets seconds_idle on access' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + manager.instance_variable_get(:@timestamps)['tenant_a'] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 600 + manager.get('tenant_a') + expect(manager.stats_for('tenant_a')[:seconds_idle]).to(be_within(1).of(0)) + end + + it 'does not create timestamps for unknown tenants' do + manager.get('unknown') + expect(manager.stats_for('unknown')).to(be_nil) + end + end + + describe '#remove' do + it 'removes a tracked pool' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + manager.remove('tenant_a') + expect(manager.tracked?('tenant_a')).to(be(false)) + end + + it 'returns the removed value' do + manager.fetch_or_create('tenant_a') { 'pool_a' } + expect(manager.remove('tenant_a')).to(eq('pool_a')) + end + + it 'returns nil for unknown tenants' do + expect(manager.remove('unknown')).to(be_nil) + end + end + + describe '#idle_tenants' do + it 'returns tenants idle beyond threshold' do + manager.fetch_or_create('old') { 'pool_old' } + manager.instance_variable_get(:@timestamps)['old'] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 600 + manager.fetch_or_create('recent') { 'pool_recent' } + + idle = manager.idle_tenants(timeout: 300) + expect(idle).to(include('old')) + expect(idle).not_to(include('recent')) + end + end + + describe '#lru_tenants' do + it 'returns tenants sorted by least recently accessed' do + manager.fetch_or_create('a') { 'pool_a' } + manager.instance_variable_get(:@timestamps)['a'] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 300 + manager.fetch_or_create('b') { 'pool_b' } + manager.instance_variable_get(:@timestamps)['b'] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 200 + manager.fetch_or_create('c') { 'pool_c' } + + lru = manager.lru_tenants(count: 2) + expect(lru).to(eq(%w[a b])) + end + end + + describe '#stats' do + it 'returns pool count and tenant list' do + manager.fetch_or_create('a') { 'pool_a' } + manager.fetch_or_create('b') { 'pool_b' } + + stats = manager.stats + expect(stats[:total_pools]).to(eq(2)) + expect(stats[:tenants]).to(contain_exactly('a', 'b')) + end + end + + describe '#clear' do + it 'removes all tracked pools' do + manager.fetch_or_create('a') { 'pool_a' } + manager.fetch_or_create('b') { 'pool_b' } + manager.clear + expect(manager.stats[:total_pools]).to(eq(0)) + end + end + + describe '#remove_tenant' do + it 'removes all pools for the given tenant prefix' do + manager.fetch_or_create('acme:writing') { 'pool_aw' } + manager.fetch_or_create('acme:reading') { 'pool_ar' } + manager.fetch_or_create('other:writing') { 'pool_ow' } + + removed = manager.remove_tenant('acme') + + expect(removed.map(&:first)).to(contain_exactly('acme:writing', 'acme:reading')) + expect(manager.tracked?('acme:writing')).to(be(false)) + expect(manager.tracked?('acme:reading')).to(be(false)) + expect(manager.tracked?('other:writing')).to(be(true)) + end + + it 'returns empty array when no pools match' do + manager.fetch_or_create('other:writing') { 'pool_ow' } + expect(manager.remove_tenant('acme')).to(eq([])) + end + end + + describe '#evict_by_role' do + it 'removes all pools with the given role suffix' do + manager.fetch_or_create('acme:writing') { 'pool_aw' } + manager.fetch_or_create('acme:db_manager') { 'pool_am' } + manager.fetch_or_create('other:db_manager') { 'pool_om' } + manager.fetch_or_create('other:writing') { 'pool_ow' } + + removed = manager.evict_by_role(:db_manager) + + expect(removed.map(&:first)).to(contain_exactly('acme:db_manager', 'other:db_manager')) + expect(manager.tracked?('acme:db_manager')).to(be(false)) + expect(manager.tracked?('other:db_manager')).to(be(false)) + expect(manager.tracked?('acme:writing')).to(be(true)) + expect(manager.tracked?('other:writing')).to(be(true)) + end + + it 'returns empty array when no pools match' do + manager.fetch_or_create('acme:writing') { 'pool_aw' } + expect(manager.evict_by_role(:db_manager)).to(eq([])) + end + end + + describe 'thread safety' do + it 'handles concurrent fetch_or_create without duplicates' do + results = Concurrent::Array.new + threads = Array.new(10) do + Thread.new { results << manager.fetch_or_create('shared') { SecureRandom.hex } } + end + threads.each(&:join) + + expect(results.uniq.size).to(eq(1)) + end + end +end diff --git a/spec/unit/pool_reaper_spec.rb b/spec/unit/pool_reaper_spec.rb new file mode 100644 index 00000000..7e04596a --- /dev/null +++ b/spec/unit/pool_reaper_spec.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Apartment::PoolReaper) do + let(:pool_manager) { Apartment::PoolManager.new } + let(:disconnect_calls) { Concurrent::Array.new } + let(:on_evict) { ->(tenant, _pool) { disconnect_calls << tenant } } + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + on_evict: on_evict + ) + end + + after { reaper.stop if reaper.running? } + + describe '#initialize' do + it 'creates without starting the timer' do + expect(reaper).not_to(be_running) + end + + it 'raises ArgumentError for zero interval' do + expect do + described_class.new(pool_manager: pool_manager, interval: 0, idle_timeout: 1) + end.to(raise_error(ArgumentError, /interval/)) + end + + it 'raises ArgumentError for negative idle_timeout' do + expect do + described_class.new(pool_manager: pool_manager, interval: 1, idle_timeout: -1) + end.to(raise_error(ArgumentError, /idle_timeout/)) + end + + it 'raises ArgumentError for non-positive max_total' do + expect do + described_class.new(pool_manager: pool_manager, interval: 1, idle_timeout: 1, max_total: 0) + end.to(raise_error(ArgumentError, /max_total/)) + end + end + + describe '#start / #stop' do + it 'can start and stop without error' do + reaper.start + expect(reaper).to(be_running) + reaper.stop + expect(reaper).not_to(be_running) + end + + it 'stop is idempotent when not running' do + expect { reaper.stop }.not_to(raise_error) + expect(reaper).not_to(be_running) + end + end + + describe 'double start' do + it 'stops the previous timer before starting a new one' do + reaper.start + expect(reaper).to(be_running) + + # Start again — should not leak the old timer + reaper.start + expect(reaper).to(be_running) + reaper.stop + expect(reaper).not_to(be_running) + end + end + + describe 'idle eviction' do + it 'evicts pools idle beyond timeout' do + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + pool_manager.fetch_or_create('fresh') { 'pool_fresh' } + + reaper.start + + sleep 0.2 + + expect(disconnect_calls).to(include('stale')) + expect(pool_manager.tracked?('stale')).to(be(false)) + expect(pool_manager.tracked?('fresh')).to(be(true)) + end + end + + describe 'max_total eviction' do + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 999, + max_total: 2, + on_evict: on_evict + ) + end + + it 'evicts LRU pools when over max' do + 3.times do |i| + pool_manager.fetch_or_create("tenant_#{i}") { "pool_#{i}" } + pool_manager.instance_variable_get(:@timestamps)["tenant_#{i}"] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - (300 - (i * 100)) + end + + reaper.start + + sleep 0.2 + + expect(pool_manager.stats[:total_pools]).to(be <= 2) + expect(disconnect_calls).to(include('tenant_0')) + end + end + + describe 'protected tenants' do + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + default_tenant: 'public', + on_evict: on_evict + ) + end + + it 'never evicts the default tenant' do + pool_manager.fetch_or_create('public') { 'pool_default' } + pool_manager.instance_variable_get(:@timestamps)['public'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 9999 + + reaper.start + + sleep 0.2 + + expect(pool_manager.tracked?('public')).to(be(true)) + expect(disconnect_calls).not_to(include('public')) + end + end + + describe 'default tenant composite key guard' do + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + default_tenant: 'public', + on_evict: on_evict + ) + end + + it 'never evicts pools whose keys start with the default tenant prefix' do + pool_manager.fetch_or_create('public:writing') { 'pool_pw' } + pool_manager.fetch_or_create('public:reading') { 'pool_pr' } + pool_manager.instance_variable_get(:@timestamps)['public:writing'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 9999 + pool_manager.instance_variable_get(:@timestamps)['public:reading'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 9999 + + reaper.start + + sleep 0.2 + + expect(pool_manager.tracked?('public:writing')).to(be(true)) + expect(pool_manager.tracked?('public:reading')).to(be(true)) + expect(disconnect_calls).not_to(include('public:writing')) + expect(disconnect_calls).not_to(include('public:reading')) + end + end + + describe 'error resilience' do + let(:bad_callback) { ->(_tenant, _pool) { raise('callback explosion') } } + let(:reaper) do + described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + on_evict: bad_callback + ) + end + + it 'continues running when on_evict callback raises' do + pool_manager.fetch_or_create('tenant_a') { 'pool_a' } + pool_manager.instance_variable_get(:@timestamps)['tenant_a'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + pool_manager.fetch_or_create('tenant_b') { 'pool_b' } + pool_manager.instance_variable_get(:@timestamps)['tenant_b'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + reaper.start + + sleep 0.3 + + # Timer should still be running despite callback errors + expect(reaper).to(be_running) + # Both tenants should still have been removed from the pool manager + # (the removal happens before the callback) + expect(pool_manager.tracked?('tenant_a')).to(be(false)) + expect(pool_manager.tracked?('tenant_b')).to(be(false)) + end + end + + describe 'instrumentation' do + it 'emits evict.apartment events on eviction' do + events = Concurrent::Array.new + ActiveSupport::Notifications.subscribe('evict.apartment') { |event| events << event } + + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + reaper.start + + sleep(0.2) + + expect(events.any? { |e| e.payload[:tenant] == 'stale' }).to(be(true)) + ensure + ActiveSupport::Notifications.unsubscribe('evict.apartment') + end + end + + describe '#run_cycle' do + it 'performs one synchronous eviction pass and returns eviction count' do + pool_manager.fetch_or_create('stale_a') { 'pool_a' } + pool_manager.fetch_or_create('stale_b') { 'pool_b' } + pool_manager.instance_variable_get(:@timestamps)['stale_a'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + pool_manager.instance_variable_get(:@timestamps)['stale_b'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + pool_manager.fetch_or_create('fresh') { 'pool_fresh' } + + count = reaper.run_cycle + expect(count).to(eq(2)) + expect(pool_manager.tracked?('stale_a')).to(be(false)) + expect(pool_manager.tracked?('stale_b')).to(be(false)) + expect(pool_manager.tracked?('fresh')).to(be(true)) + end + + it 'returns 0 when nothing to evict' do + pool_manager.fetch_or_create('fresh') { 'pool_fresh' } + count = reaper.run_cycle + expect(count).to(eq(0)) + end + + it 'does not require the background timer to be running' do + expect(reaper).not_to(be_running) + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + count = reaper.run_cycle + expect(count).to(eq(1)) + end + + it 'respects default_tenant protection' do + protected_reaper = described_class.new( + pool_manager: pool_manager, + interval: 0.05, + idle_timeout: 1, + default_tenant: 'public', + on_evict: on_evict + ) + pool_manager.fetch_or_create('public') { 'pool_default' } + pool_manager.instance_variable_get(:@timestamps)['public'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 9999 + pool_manager.fetch_or_create('stale') { 'pool_stale' } + pool_manager.instance_variable_get(:@timestamps)['stale'] = + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 10 + + count = protected_reaper.run_cycle + expect(count).to(eq(1)) + expect(pool_manager.tracked?('public')).to(be(true)) + expect(pool_manager.tracked?('stale')).to(be(false)) + end + end +end diff --git a/spec/unit/railtie_spec.rb b/spec/unit/railtie_spec.rb new file mode 100644 index 00000000..ba054062 --- /dev/null +++ b/spec/unit/railtie_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Railtie loads automatically when Rails is present (via lib/apartment.rb). +# Without Rails, Apartment::Railtie is not defined — skip gracefully. +RSpec.describe('Apartment::Railtie') do + before do + skip 'requires Rails (run via appraisal)' unless defined?(Apartment::Railtie) + end + + describe '.resolve_elevator_class' do + it 'resolves :subdomain to Apartment::Elevators::Subdomain' do + klass = Apartment::Railtie.resolve_elevator_class(:subdomain) + expect(klass).to(eq(Apartment::Elevators::Subdomain)) + end + + it 'resolves :domain to Apartment::Elevators::Domain' do + klass = Apartment::Railtie.resolve_elevator_class(:domain) + expect(klass).to(eq(Apartment::Elevators::Domain)) + end + + it 'resolves :host to Apartment::Elevators::Host' do + klass = Apartment::Railtie.resolve_elevator_class(:host) + expect(klass).to(eq(Apartment::Elevators::Host)) + end + + it 'raises ConfigurationError for unknown elevator' do + expect { Apartment::Railtie.resolve_elevator_class(:nonexistent) } + .to(raise_error(Apartment::ConfigurationError, /Unknown elevator.*nonexistent/)) + end + + it 'includes available elevators in the error message' do + expect { Apartment::Railtie.resolve_elevator_class(:nonexistent) } + .to(raise_error(Apartment::ConfigurationError, /subdomain/)) + end + + it 'passes through a class without resolution' do + klass = Apartment::Railtie.resolve_elevator_class(Apartment::Elevators::Subdomain) + expect(klass).to(eq(Apartment::Elevators::Subdomain)) + end + + it 'passes through any custom class' do + custom_class = Class.new(Apartment::Elevators::Generic) + klass = Apartment::Railtie.resolve_elevator_class(custom_class) + expect(klass).to(eq(custom_class)) + end + + it 'resolves :header to Apartment::Elevators::Header' do + klass = Apartment::Railtie.resolve_elevator_class(:header) + expect(klass).to(eq(Apartment::Elevators::Header)) + end + end + + describe '.header_trust_warning?' do + it 'returns true for Header with trusted: false' do + expect(Apartment::Railtie.header_trust_warning?(Apartment::Elevators::Header, {})).to(be(true)) + end + + it 'returns false for Header with trusted: true' do + expect(Apartment::Railtie.header_trust_warning?(Apartment::Elevators::Header, { trusted: true })).to(be(false)) + end + + it 'returns true for Header subclass with trusted: false' do + subclass = Class.new(Apartment::Elevators::Header) + expect(Apartment::Railtie.header_trust_warning?(subclass, {})).to(be(true)) + end + + it 'returns false for non-Header elevator' do + expect(Apartment::Railtie.header_trust_warning?(Apartment::Elevators::Subdomain, {})).to(be(false)) + end + end +end diff --git a/spec/unit/reloader_spec.rb b/spec/unit/reloader_spec.rb deleted file mode 100644 index 54bd2ff7..00000000 --- a/spec/unit/reloader_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Apartment::Reloader do - context 'using postgresql schemas' do - before do - Apartment.configure do |config| - config.excluded_models = ['Company'] - config.use_schemas = true - end - Apartment::Tenant.reload!(config) - Company.reset_table_name # ensure we're clean - end - - subject { Apartment::Reloader.new(double('Rack::Application', call: nil)) } - - it 'should initialize apartment when called' do - expect(Company.table_name).not_to include('public.') - subject.call(double('env')) - expect(Company.table_name).to include('public.') - end - end -end diff --git a/spec/unit/schema_cache_spec.rb b/spec/unit/schema_cache_spec.rb new file mode 100644 index 00000000..65348250 --- /dev/null +++ b/spec/unit/schema_cache_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/schema_cache' + +RSpec.describe(Apartment::SchemaCache) do + describe '.cache_path_for' do + it 'returns db/schema_cache_.yml' do + path = described_class.cache_path_for('acme') + expect(path).to(end_with('db/schema_cache_acme.yml')) + end + end + + describe '.cache_path_for used by ConnectionHandling' do + it 'generates paths that File.exist? can check' do + path = described_class.cache_path_for('acme') + expect(path).to(be_a(String)) + expect(path).not_to(be_empty) + expect(path).to(include('schema_cache_acme')) + end + end + + describe '.dump' do + it 'switches to tenant and dumps schema cache' do + schema_cache = double('schema_cache') + connection = double('connection', schema_cache: schema_cache) + allow(Apartment::Tenant).to(receive(:switch).and_yield) + allow(ActiveRecord::Base).to(receive(:connection).and_return(connection)) + allow(schema_cache).to(receive(:dump_to)) + + path = described_class.dump('acme') + + expect(Apartment::Tenant).to(have_received(:switch).with('acme')) + expect(schema_cache).to(have_received(:dump_to).with(path)) + expect(path).to(end_with('schema_cache_acme.yml')) + end + end + + describe '.dump_all' do + it 'dumps for each tenant from provider' do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { %w[t1 t2] } + c.default_tenant = 'public' + end + allow(described_class).to(receive(:dump).and_return('path')) + + described_class.dump_all + + expect(described_class).to(have_received(:dump).with('t1')) + expect(described_class).to(have_received(:dump).with('t2')) + end + end +end diff --git a/spec/unit/schema_dumper_patch_spec.rb b/spec/unit/schema_dumper_patch_spec.rb new file mode 100644 index 00000000..7dd33460 --- /dev/null +++ b/spec/unit/schema_dumper_patch_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/schema_dumper_patch' + +RSpec.describe(Apartment::SchemaDumperPatch) do + describe '.strip_public_prefix' do + it 'strips public. prefix from table name' do + expect(described_class.strip_public_prefix('public.users')).to(eq('users')) + end + + it 'leaves non-public schemas intact' do + expect(described_class.strip_public_prefix('extensions.uuid_ossp')).to(eq('extensions.uuid_ossp')) + end + + it 'leaves unqualified names unchanged' do + expect(described_class.strip_public_prefix('users')).to(eq('users')) + end + + it 'respects include_schemas_in_dump' do + expect(described_class.strip_public_prefix('shared.lookups', include_schemas: %w[shared])) + .to(eq('shared.lookups')) + end + + it 'strips public. even when include_schemas is set' do + expect(described_class.strip_public_prefix('public.users', include_schemas: %w[shared])) + .to(eq('users')) + end + end + + describe '.should_patch?' do + it 'returns true when Rails >= 8.1 and SchemaDumper is defined' do + allow(ActiveRecord).to(receive(:gem_version).and_return(Gem::Version.new('8.1.0'))) + expect(described_class.should_patch?).to(be(true)) + end + + it 'returns false when Rails < 8.1' do + allow(ActiveRecord).to(receive(:gem_version).and_return(Gem::Version.new('8.0.5'))) + expect(described_class.should_patch?).to(be(false)) + end + end + + describe '.apply!' do + it 'prepends DumperOverride on the PG-specific SchemaDumper for Rails >= 8.1' do + allow(described_class).to(receive(:should_patch?).and_return(true)) + + # Verify the PG SchemaDumper exists (it does in our test matrix) + if defined?(ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper) + described_class.apply! + expect(ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.ancestors) + .to(include(Apartment::SchemaDumperPatch::DumperOverride)) + end + end + + it 'is a no-op when should_patch? is false' do + allow(described_class).to(receive(:should_patch?).and_return(false)) + described_class.apply! + # No error raised, no prepend attempted + end + end + + describe 'DumperOverride#relation_name' do + let(:override_instance) do + obj = Object.new + obj.extend(Apartment::SchemaDumperPatch::DumperOverride) + obj + end + + before do + Apartment.configure do |c| + c.tenant_strategy = :schema + c.tenants_provider = -> { [] } + c.default_tenant = 'public' + c.configure_postgres do |pg| + pg.include_schemas_in_dump = %w[shared] + end + end + end + + it 'strips public. prefix from relation names' do + # Simulate what Rails 8.1 relation_name returns before our override + allow(override_instance).to(receive(:relation_name).and_call_original) + # The super call in DumperOverride would return the schema-qualified name; + # we test the strip_public_prefix logic directly since we can't easily + # set up the full SchemaDumper inheritance chain in a unit test. + expect(described_class.strip_public_prefix('public.users')).to(eq('users')) + end + + it 'preserves schemas listed in include_schemas_in_dump' do + expect(described_class.strip_public_prefix('shared.lookups', include_schemas: %w[shared])) + .to(eq('shared.lookups')) + end + end +end diff --git a/spec/unit/sql_query_tags_spec.rb b/spec/unit/sql_query_tags_spec.rb new file mode 100644 index 00000000..421b000d --- /dev/null +++ b/spec/unit/sql_query_tags_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# QueryLogs requires ActiveRecord 7.1+. +return unless defined?(ActiveRecord::QueryLogs) + +RSpec.describe('sql_query_tags Railtie wiring') do + after do + # Clean up: remove :tenant from tags and taggings if we added it. + if ActiveRecord::QueryLogs.tags.include?(:tenant) + ActiveRecord::QueryLogs.tags = ActiveRecord::QueryLogs.tags - [:tenant] + end + if ActiveRecord::QueryLogs.taggings.key?(:tenant) + ActiveRecord::QueryLogs.taggings = ActiveRecord::QueryLogs.taggings.except(:tenant) + end + end + + describe 'Apartment.activate_sql_query_tags!' do + context 'when sql_query_tags is true' do + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.sql_query_tags = true + end + end + + it 'registers a :tenant tagging that reads Apartment::Current.tenant' do + Apartment.activate_sql_query_tags! + + expect(ActiveRecord::QueryLogs.taggings).to(have_key(:tenant)) + + Apartment::Current.tenant = 'acme' + expect(ActiveRecord::QueryLogs.taggings[:tenant].call).to(eq('acme')) + ensure + Apartment::Current.reset + end + + it 'adds :tenant to the active tags list' do + Apartment.activate_sql_query_tags! + + expect(ActiveRecord::QueryLogs.tags).to(include(:tenant)) + end + + it 'does not duplicate :tenant if called twice' do + Apartment.activate_sql_query_tags! + Apartment.activate_sql_query_tags! + + expect(ActiveRecord::QueryLogs.tags.count(:tenant)).to(eq(1)) + end + end + + context 'when sql_query_tags is false' do + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.sql_query_tags = false + end + end + + it 'does not register a :tenant tagging' do + Apartment.activate_sql_query_tags! + + expect(ActiveRecord::QueryLogs.taggings).not_to(have_key(:tenant)) + end + end + end +end diff --git a/spec/unit/tenant_name_validator_spec.rb b/spec/unit/tenant_name_validator_spec.rb new file mode 100644 index 00000000..4f859a8f --- /dev/null +++ b/spec/unit/tenant_name_validator_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../lib/apartment/tenant_name_validator' + +RSpec.describe(Apartment::TenantNameValidator) do + describe '.validate! common rules' do + it 'rejects nil' do + expect { described_class.validate!(nil, strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /must be a String/)) + end + + it 'rejects empty string' do + expect { described_class.validate!('', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /cannot be empty/)) + end + + it 'rejects NUL bytes' do + expect { described_class.validate!("foo\x00bar", strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /NUL byte/)) + end + + it 'rejects whitespace' do + expect { described_class.validate!('foo bar', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /whitespace/)) + end + + it 'rejects tabs' do + expect { described_class.validate!("foo\tbar", strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /whitespace/)) + end + + it 'rejects names containing colons' do + expect do + described_class.validate!('tenant:name', strategy: :schema) + end.to(raise_error(Apartment::ConfigurationError, /colon/)) + end + + it 'rejects names longer than 255 characters' do + expect { described_class.validate!('a' * 256, strategy: :database_name, adapter_name: 'sqlite3') } + .to(raise_error(Apartment::ConfigurationError, /too long.*256.*max 255/)) + end + + it 'accepts valid names' do + expect { described_class.validate!('acme', strategy: :schema) }.not_to(raise_error) + end + end + + describe 'PostgreSQL identifier rules' do + it 'rejects names longer than 63 characters' do + expect { described_class.validate!('a' * 64, strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /too long.*64.*max 63/)) + end + + it 'rejects names starting with pg_' do + expect { described_class.validate!('pg_custom', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /reserved prefix/)) + end + + it 'rejects names starting with a digit' do + expect { described_class.validate!('123abc', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /Invalid PostgreSQL identifier/)) + end + + it 'rejects names with special characters' do + expect { described_class.validate!('foo@bar', strategy: :schema) } + .to(raise_error(Apartment::ConfigurationError, /Invalid PostgreSQL identifier/)) + end + + it 'allows hyphens (quoted by adapters)' do + expect { described_class.validate!('my-tenant', strategy: :schema) }.not_to(raise_error) + end + + it 'allows underscores' do + expect { described_class.validate!('my_tenant', strategy: :schema) }.not_to(raise_error) + end + + it 'allows names starting with underscore' do + expect { described_class.validate!('_private', strategy: :schema) }.not_to(raise_error) + end + + it 'accepts exactly 63 characters' do + expect { described_class.validate!('a' * 63, strategy: :schema) }.not_to(raise_error) + end + end + + describe 'MySQL database name rules' do + let(:opts) { { strategy: :database_name, adapter_name: 'mysql2' } } + + it 'rejects names longer than 64 characters' do + expect { described_class.validate!('a' * 65, **opts) } + .to(raise_error(Apartment::ConfigurationError, /too long.*65.*max 64/)) + end + + it 'rejects names starting with a digit' do + expect { described_class.validate!('123abc', **opts) } + .to(raise_error(Apartment::ConfigurationError, /cannot start with a digit/)) + end + + it 'rejects names ending with a period' do + expect { described_class.validate!('foo.', **opts) } + .to(raise_error(Apartment::ConfigurationError, /cannot end with a period/)) + end + + it 'rejects names with invalid characters' do + expect { described_class.validate!('foo@bar', **opts) } + .to(raise_error(Apartment::ConfigurationError, /Invalid MySQL/)) + end + + it 'allows hyphens and dollar signs' do + expect { described_class.validate!('my-tenant$1', **opts) }.not_to(raise_error) + end + + it 'applies to trilogy adapter' do + expect { described_class.validate!('foo@bar', strategy: :database_name, adapter_name: 'trilogy') } + .to(raise_error(Apartment::ConfigurationError, /Invalid MySQL/)) + end + + it 'accepts exactly 64 characters' do + expect { described_class.validate!('a' * 64, **opts) }.not_to(raise_error) + end + end + + describe 'SQLite path rules' do + let(:opts) { { strategy: :database_name, adapter_name: 'sqlite3' } } + + it 'rejects path traversal' do + expect { described_class.validate!('../escape', **opts) } + .to(raise_error(Apartment::ConfigurationError, /path traversal/)) + end + + it 'rejects forward slash path separators' do + expect { described_class.validate!('dir/name', **opts) } + .to(raise_error(Apartment::ConfigurationError, /path separators/)) + end + + it 'rejects backslash path separators' do + expect { described_class.validate!('dir\\name', **opts) } + .to(raise_error(Apartment::ConfigurationError, /path separators/)) + end + + it 'allows normal names' do + expect { described_class.validate!('my_tenant', **opts) }.not_to(raise_error) + end + + it 'allows hyphens and dots in names' do + expect { described_class.validate!('my-tenant.v2', **opts) }.not_to(raise_error) + end + end + + describe 'unknown strategy' do + it 'applies only common validation for :shard strategy' do + expect { described_class.validate!('acme', strategy: :shard) }.not_to(raise_error) + end + + it 'applies only common validation for :database_config strategy' do + expect { described_class.validate!('acme', strategy: :database_config) }.not_to(raise_error) + end + end +end diff --git a/spec/unit/tenant_spec.rb b/spec/unit/tenant_spec.rb new file mode 100644 index 00000000..04333bf4 --- /dev/null +++ b/spec/unit/tenant_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe(Apartment::Tenant) do + let(:mock_adapter) { double('Adapter') } + + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { %w[tenant1 tenant2] } + config.default_tenant = 'public' + end + Apartment.adapter = mock_adapter + Apartment::Current.reset + end + + describe '.switch' do + it 'requires a block' do + expect { described_class.switch('tenant1') }.to(raise_error(ArgumentError, /requires a block/)) + end + + it 'sets Current.tenant and Current.previous_tenant within the block' do + described_class.switch('tenant1') do + expect(Apartment::Current.tenant).to(eq('tenant1')) + expect(Apartment::Current.previous_tenant).to(be_nil) + end + end + + it 'tracks the previous tenant when nested' do + Apartment::Current.tenant = 'base' + + described_class.switch('tenant1') do + expect(Apartment::Current.tenant).to(eq('tenant1')) + expect(Apartment::Current.previous_tenant).to(eq('base')) + end + end + + it 'restores the previous tenant after the block' do + Apartment::Current.tenant = 'base' + + described_class.switch('tenant1') {} + + expect(Apartment::Current.tenant).to(eq('base')) + expect(Apartment::Current.previous_tenant).to(be_nil) + end + + it 'restores the previous tenant on exception' do + Apartment::Current.tenant = 'base' + + expect do + described_class.switch('tenant1') { raise('boom') } + end.to(raise_error(RuntimeError, 'boom')) + + expect(Apartment::Current.tenant).to(eq('base')) + expect(Apartment::Current.previous_tenant).to(be_nil) + end + + it 'supports nesting' do + described_class.switch('tenant1') do + described_class.switch('tenant2') do + expect(Apartment::Current.tenant).to(eq('tenant2')) + expect(Apartment::Current.previous_tenant).to(eq('tenant1')) + end + expect(Apartment::Current.tenant).to(eq('tenant1')) + end + end + end + + describe '.switch!' do + it 'sets the current tenant without a block' do + described_class.switch!('tenant1') + expect(Apartment::Current.tenant).to(eq('tenant1')) + end + + it 'sets previous_tenant to the prior tenant' do + Apartment::Current.tenant = 'base' + described_class.switch!('tenant1') + expect(Apartment::Current.previous_tenant).to(eq('base')) + end + end + + describe '.current' do + it 'returns Current.tenant when set' do + Apartment::Current.tenant = 'tenant1' + expect(described_class.current).to(eq('tenant1')) + end + + it 'falls back to config.default_tenant when Current.tenant is nil' do + expect(described_class.current).to(eq('public')) + end + + it 'returns nil when no config and no current tenant' do + Apartment.clear_config + expect(described_class.current).to(be_nil) + end + end + + describe '.reset' do + it 'sets tenant to default_tenant' do + Apartment::Current.tenant = 'tenant1' + described_class.reset + expect(Apartment::Current.tenant).to(eq('public')) + end + + it 'sets previous_tenant to the prior tenant' do + Apartment::Current.tenant = 'tenant1' + described_class.reset + expect(Apartment::Current.previous_tenant).to(eq('tenant1')) + end + end + + describe '.init' do + it 'delegates to adapter.process_pinned_models' do + expect(mock_adapter).to(receive(:process_pinned_models)) + described_class.init + end + + context 'resolve_excluded_models_shim' do + it 'resolves excluded model strings and registers them as pinned' do + model_class = Class.new + stub_const('ShimTestModel', model_class) + + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'public' + config.excluded_models = ['ShimTestModel'] + end + + allow(mock_adapter).to(receive(:process_pinned_models)) + Apartment.adapter = mock_adapter + + described_class.init + + expect(Apartment.pinned_models).to(include(ShimTestModel)) + end + + it 'raises ConfigurationError for unresolvable model names' do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'public' + config.excluded_models = ['NonExistentModel'] + end + + allow(mock_adapter).to(receive(:process_pinned_models)) + Apartment.adapter = mock_adapter + + expect { described_class.init }.to(raise_error( + Apartment::ConfigurationError, + /Excluded model 'NonExistentModel' could not be resolved/ + )) + end + + it 'skips models already in pinned_models registry (via pin_tenant)' do + require 'apartment/concerns/model' + model_class = Class.new do + include Apartment::Model + end + stub_const('AlreadyPinnedModel', model_class) + AlreadyPinnedModel.pin_tenant + + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.default_tenant = 'public' + config.excluded_models = ['AlreadyPinnedModel'] + end + + allow(mock_adapter).to(receive(:process_pinned_models)) + Apartment.adapter = mock_adapter + + # Already in registry via pin_tenant — should not double-register + count_before = Apartment.pinned_models.size + described_class.init + expect(Apartment.pinned_models.size).to(eq(count_before)) + end + end + end + + describe '.create' do + it 'delegates to adapter' do + expect(mock_adapter).to(receive(:create).with('new_tenant')) + described_class.create('new_tenant') + end + end + + describe '.drop' do + it 'delegates to adapter' do + expect(mock_adapter).to(receive(:drop).with('old_tenant')) + described_class.drop('old_tenant') + end + end + + describe '.migrate' do + it 'delegates to adapter with tenant' do + expect(mock_adapter).to(receive(:migrate).with('tenant1', nil)) + described_class.migrate('tenant1') + end + + it 'delegates to adapter with tenant and version' do + expect(mock_adapter).to(receive(:migrate).with('tenant1', 20_260_101_000_000)) + described_class.migrate('tenant1', 20_260_101_000_000) + end + end + + describe '.seed' do + it 'delegates to adapter' do + expect(mock_adapter).to(receive(:seed).with('tenant1')) + described_class.seed('tenant1') + end + end + + describe 'adapter guard' do + it 'raises ConfigurationError when adapter is not configured' do + Apartment.clear_config + expect { described_class.create('tenant1') }.to(raise_error( + Apartment::ConfigurationError, /not configured/ + )) + end + end + + describe '.pool_stats' do + it 'delegates to pool_manager.stats' do + stats = { total: 2, active: 1 } + allow(Apartment.pool_manager).to(receive(:stats).and_return(stats)) + expect(described_class.pool_stats).to(eq(stats)) + end + + it 'returns empty hash when pool_manager is nil' do + Apartment.clear_config + expect(described_class.pool_stats).to(eq({})) + end + end +end diff --git a/spec/unit/tenant_tagged_logging_spec.rb b/spec/unit/tenant_tagged_logging_spec.rb new file mode 100644 index 00000000..bdaf96f6 --- /dev/null +++ b/spec/unit/tenant_tagged_logging_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'active_support/tagged_logging' + +RSpec.describe('Apartment::Tenant.switch tagged logging') do + let(:output) { StringIO.new } + let(:logger) { ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(output)) } + + # Provide a minimal Rails.logger for unit testing without booting Rails. + before do + stub_const('Rails', Module.new) unless defined?(Rails) + unless Rails.respond_to?(:logger) + Rails.define_singleton_method(:logger) { @_test_logger } + Rails.define_singleton_method(:logger=) { |l| @_test_logger = l } + end + @original_logger = Rails.logger + Rails.logger = logger + end + + after do + Rails.logger = @original_logger + end + + context 'when active_record_log is true' do + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.active_record_log = true + end + end + + it 'tags log output with the tenant name during the switch block' do + Apartment::Tenant.switch('acme') do + Rails.logger.info('inside switch') + end + + expect(output.string).to(include('[tenant=acme]')) + expect(output.string).to(include('inside switch')) + end + + it 'removes the tag after the switch block completes' do + Apartment::Tenant.switch('acme') do + Rails.logger.info('inside') + end + + output.truncate(0) + output.rewind + Rails.logger.info('outside') + + expect(output.string).not_to(include('[tenant=acme]')) + end + + it 'handles nested switches with correct tags' do + Apartment::Tenant.switch('acme') do + Apartment::Tenant.switch('widgets') do + Rails.logger.info('inner') + end + Rails.logger.info('outer') + end + + lines = output.string.split("\n") + expect(lines[0]).to(include('[tenant=widgets]')) + expect(lines[0]).to(include('inner')) + expect(lines[1]).to(include('[tenant=acme]')) + expect(lines[1]).not_to(include('[tenant=widgets]')) + end + end + + context 'when active_record_log is false' do + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.active_record_log = false + end + end + + it 'does not tag log output' do + Apartment::Tenant.switch('acme') do + Rails.logger.info('no tag expected') + end + + expect(output.string).not_to(include('[tenant=acme]')) + expect(output.string).to(include('no tag expected')) + end + end + + context 'when logger does not support tagged' do + let(:plain_logger) { ActiveSupport::Logger.new(output) } + + before do + Apartment.configure do |config| + config.tenant_strategy = :schema + config.tenants_provider = -> { [] } + config.active_record_log = true + end + Rails.logger = plain_logger + end + + it 'does not raise and passes through' do + expect do + Apartment::Tenant.switch('acme') do + Rails.logger.info('plain logger') + end + end.not_to(raise_error) + + expect(output.string).to(include('plain logger')) + end + end +end