├── .ruby-version
├── spec
├── dummy
│ ├── public
│ │ ├── favicon.ico
│ │ ├── stylesheets
│ │ │ └── .gitkeep
│ │ ├── 422.html
│ │ ├── 404.html
│ │ └── 500.html
│ ├── app
│ │ ├── views
│ │ │ ├── application
│ │ │ │ └── index.html.erb
│ │ │ └── layouts
│ │ │ │ └── application.html.erb
│ │ ├── helpers
│ │ │ └── application_helper.rb
│ │ ├── models
│ │ │ ├── user.rb
│ │ │ ├── company.rb
│ │ │ ├── application_record.rb
│ │ │ └── user_with_tenant_model.rb
│ │ └── controllers
│ │ │ └── application_controller.rb
│ ├── config
│ │ ├── routes.rb
│ │ ├── initializers
│ │ │ ├── apartment.rb
│ │ │ ├── mime_types.rb
│ │ │ ├── inflections.rb
│ │ │ ├── backtrace_silencers.rb
│ │ │ ├── session_store.rb
│ │ │ └── secret_token.rb
│ │ ├── environment.rb
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── boot.rb
│ │ ├── environments
│ │ │ ├── development.rb
│ │ │ ├── test.rb
│ │ │ └── production.rb
│ │ └── application.rb
│ ├── db
│ │ ├── seeds.rb
│ │ ├── seeds
│ │ │ └── import.rb
│ │ ├── migrate
│ │ │ ├── 20111202022214_create_table_books.rb
│ │ │ ├── 20180415260934_create_public_tokens.rb
│ │ │ └── 20110613152810_create_dummy_models.rb
│ │ └── schema.rb
│ ├── config.ru
│ ├── Rakefile
│ └── script
│ │ └── rails
├── dummy_engine
│ ├── lib
│ │ ├── dummy_engine
│ │ │ ├── version.rb
│ │ │ └── engine.rb
│ │ └── dummy_engine.rb
│ ├── .gitignore
│ ├── test
│ │ └── dummy
│ │ │ ├── config.ru
│ │ │ ├── config
│ │ │ ├── initializers
│ │ │ │ ├── cookies_serializer.rb
│ │ │ │ ├── session_store.rb
│ │ │ │ ├── mime_types.rb
│ │ │ │ ├── filter_parameter_logging.rb
│ │ │ │ ├── assets.rb
│ │ │ │ ├── backtrace_silencers.rb
│ │ │ │ ├── wrap_parameters.rb
│ │ │ │ └── inflections.rb
│ │ │ ├── environment.rb
│ │ │ ├── boot.rb
│ │ │ ├── database.yml
│ │ │ ├── locales
│ │ │ │ └── en.yml
│ │ │ ├── application.rb
│ │ │ ├── secrets.yml
│ │ │ ├── environments
│ │ │ │ ├── development.rb
│ │ │ │ ├── test.rb
│ │ │ │ └── production.rb
│ │ │ └── routes.rb
│ │ │ └── Rakefile
│ ├── bin
│ │ └── rails
│ ├── Gemfile
│ ├── Rakefile
│ └── config
│ │ └── initializers
│ │ └── apartment.rb
├── config
│ ├── sqlite.yml.erb
│ ├── mysql.yml.erb
│ └── postgresql.yml.erb
├── support
│ ├── config.rb
│ ├── requirements.rb
│ ├── apartment_helpers.rb
│ ├── setup.rb
│ └── contexts.rb
├── adapters
│ ├── jdbc_mysql_adapter_spec.rb
│ ├── jdbc_postgresql_adapter_spec.rb
│ ├── mysql2_adapter_spec.rb
│ ├── trilogy_adapter_spec.rb
│ └── sqlite3_adapter_spec.rb
├── schemas
│ ├── v1.rb
│ ├── v2.rb
│ └── v3.rb
├── unit
│ ├── elevators
│ │ ├── first_subdomain_spec.rb
│ │ ├── domain_spec.rb
│ │ ├── host_hash_spec.rb
│ │ ├── generic_spec.rb
│ │ ├── subdomain_spec.rb
│ │ └── host_spec.rb
│ ├── migrator_spec.rb
│ └── config_spec.rb
├── integration
│ ├── use_within_an_engine_spec.rb
│ ├── connection_handling_spec.rb
│ ├── query_caching_spec.rb
│ └── apartment_rake_integration_spec.rb
├── examples
│ ├── connection_adapter_examples.rb
│ ├── generic_adapters_callbacks_examples.rb
│ └── generic_adapter_custom_configuration_example.rb
├── apartment_spec.rb
├── spec_helper.rb
└── tasks
│ └── apartment_rake_spec.rb
├── .rspec
├── docs
└── images
│ └── log_example.png
├── lib
├── apartment
│ ├── version.rb
│ ├── deprecation.rb
│ ├── active_record
│ │ ├── internal_metadata.rb
│ │ ├── schema_migration.rb
│ │ ├── postgres
│ │ │ └── schema_dumper.rb
│ │ ├── connection_handling.rb
│ │ └── postgresql_adapter.rb
│ ├── adapters
│ │ ├── postgis_adapter.rb
│ │ ├── jdbc_mysql_adapter.rb
│ │ ├── abstract_jdbc_adapter.rb
│ │ ├── trilogy_adapter.rb
│ │ ├── sqlite3_adapter.rb
│ │ ├── mysql2_adapter.rb
│ │ └── jdbc_postgresql_adapter.rb
│ ├── elevators
│ │ ├── first_subdomain.rb
│ │ ├── domain.rb
│ │ ├── host_hash.rb
│ │ ├── generic.rb
│ │ ├── host.rb
│ │ └── subdomain.rb
│ ├── console.rb
│ ├── model.rb
│ ├── log_subscriber.rb
│ ├── custom_console.rb
│ ├── tenant.rb
│ ├── migrator.rb
│ ├── railtie.rb
│ └── tasks
│ │ └── enhancements.rb
├── generators
│ └── apartment
│ │ └── install
│ │ ├── USAGE
│ │ └── install_generator.rb
└── tasks
│ └── apartment.rake
├── context7.json
├── .pryrc
├── .gitignore
├── Guardfile
├── .github
├── ISSUE_TEMPLATE.md
└── workflows
│ ├── rubocop.yml
│ ├── gem-publish.yml
│ ├── rspec_sqlite_3.yml
│ ├── rspec_mysql_8_0.yml
│ ├── rspec_pg_14.yml
│ ├── rspec_pg_15.yml
│ ├── rspec_pg_16.yml
│ ├── rspec_pg_17.yml
│ └── rspec_pg_18.yml
├── Gemfile
├── gemfiles
├── rails_7_0_mysql.gemfile
├── rails_7_0_postgresql.gemfile
├── rails_7_0_sqlite3.gemfile
├── rails_7_1_mysql.gemfile
├── rails_7_1_postgresql.gemfile
├── rails_7_1_sqlite3.gemfile
├── rails_7_2_mysql.gemfile
├── rails_7_2_postgresql.gemfile
├── rails_7_2_sqlite3.gemfile
├── rails_8_0_mysql.gemfile
├── rails_8_0_postgresql.gemfile
├── rails_8_0_sqlite3.gemfile
├── rails_8_1_mysql.gemfile
├── rails_8_1_postgresql.gemfile
├── rails_8_1_sqlite3.gemfile
├── rails_7_0_jdbc_mysql.gemfile
├── rails_7_0_jdbc_sqlite3.gemfile
└── rails_7_0_jdbc_postgresql.gemfile
├── AGENTS.md
├── ros-apartment.gemspec
├── RELEASING.md
├── Appraisals
├── .rubocop.yml
└── CODE_OF_CONDUCT.md
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.4.7
2 |
--------------------------------------------------------------------------------
/spec/dummy/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/public/stylesheets/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/application/index.html.erb:
--------------------------------------------------------------------------------
1 |
Index!!
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --colour
2 | --format documentation
3 | --tty
4 | --order random
5 |
--------------------------------------------------------------------------------
/docs/images/log_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-on-services/apartment/HEAD/docs/images/log_example.png
--------------------------------------------------------------------------------
/lib/apartment/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Apartment
4 | VERSION = '3.4.1'
5 | end
6 |
--------------------------------------------------------------------------------
/spec/dummy/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ApplicationHelper
4 | end
5 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class User < ApplicationRecord
4 | # Dummy models
5 | end
6 |
--------------------------------------------------------------------------------
/context7.json:
--------------------------------------------------------------------------------
1 | {
2 | "url": "https://context7.com/rails-on-services/apartment",
3 | "public_key": "pk_EQhqzkh8FktmxBU0mbzmZ"
4 | }
5 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/company.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Company < ApplicationRecord
4 | # Dummy models
5 | end
6 |
--------------------------------------------------------------------------------
/spec/dummy_engine/lib/dummy_engine/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DummyEngine
4 | VERSION = '0.0.1'
5 | end
6 |
--------------------------------------------------------------------------------
/spec/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Dummy::Application.routes.draw do
4 | root to: 'application#index'
5 | end
6 |
--------------------------------------------------------------------------------
/spec/dummy_engine/lib/dummy_engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'dummy_engine/engine'
4 |
5 | module DummyEngine
6 | end
7 |
--------------------------------------------------------------------------------
/lib/generators/apartment/install/USAGE:
--------------------------------------------------------------------------------
1 | Description:
2 | Creates an initializer for apartment.
3 |
4 | Example:
5 | `rails generate apartment:install`
6 |
--------------------------------------------------------------------------------
/spec/dummy_engine/lib/dummy_engine/engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DummyEngine
4 | class Engine < ::Rails::Engine
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/.pryrc:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # rubocop:disable Style/MixinUsage
4 | extend Rails::ConsoleMethods if defined?(Rails) && Rails.env
5 | # rubocop:enable Style/MixinUsage
6 |
--------------------------------------------------------------------------------
/spec/dummy/db/seeds.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | def create_users
4 | 3.times { |x| User.where(name: "Some User #{x}").first_or_create! }
5 | end
6 |
7 | create_users
8 |
--------------------------------------------------------------------------------
/spec/dummy/db/seeds/import.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | def create_users
4 | 6.times { |x| User.where(name: "Different User #{x}").first_or_create! }
5 | end
6 |
7 | create_users
8 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # NOTE: Dummy model base
4 | class ApplicationRecord < ActiveRecord::Base
5 | self.abstract_class = true
6 | end
7 |
--------------------------------------------------------------------------------
/spec/dummy_engine/.gitignore:
--------------------------------------------------------------------------------
1 | .bundle/
2 | log/*.log
3 | pkg/
4 | test/dummy/db/*.sqlite3
5 | test/dummy/db/*.sqlite3-journal
6 | test/dummy/log/*.log
7 | test/dummy/tmp/
8 | test/dummy/.sass-cache
9 |
--------------------------------------------------------------------------------
/spec/dummy/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationController < ActionController::Base
4 | protect_from_forgery
5 |
6 | def index; end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/config/sqlite.yml.erb:
--------------------------------------------------------------------------------
1 | <% unless defined?(JRUBY_VERSION) %>
2 | connections:
3 | sqlite:
4 | adapter: sqlite3
5 | database: <%= File.expand_path('../spec/dummy/db', __FILE__) %>/test.sqlite3
6 | <% end %>
7 |
--------------------------------------------------------------------------------
/spec/dummy/config.ru:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is used by Rack-based servers to start the application.
4 |
5 | require File.expand_path('config/environment', __dir__)
6 | run Dummy::Application
7 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/apartment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Apartment.configure do |config|
4 | config.excluded_models = ['Company']
5 | config.tenant_names = -> { Company.pluck(:database) }
6 | end
7 |
--------------------------------------------------------------------------------
/spec/dummy/config/environment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Load the rails application
4 | require File.expand_path('application', __dir__)
5 |
6 | # Initialize the rails application
7 | Dummy::Application.initialize!
8 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config.ru:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is used by Rack-based servers to start the application.
4 |
5 | require File.expand_path('config/environment', __dir__)
6 | run Rails.application
7 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | Rails.application.config.action_dispatch.cookies_serializer = :json
6 |
--------------------------------------------------------------------------------
/lib/apartment/deprecation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'active_support/deprecation'
4 | require_relative 'version'
5 |
6 | module Apartment
7 | DEPRECATOR = ActiveSupport::Deprecation.new(Apartment::VERSION, 'Apartment')
8 | end
9 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/environment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Load the Rails application.
4 | require File.expand_path('application', __dir__)
5 |
6 | # Initialize the Rails application.
7 | Rails.application.initialize!
8 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/session_store.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | Rails.application.config.session_store(:cookie_store, key: '_dummy_session')
6 |
--------------------------------------------------------------------------------
/spec/dummy/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Sample localization file for English. Add more files in this directory for other locales.
2 | # See http://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
3 |
4 | en:
5 | hello: "Hello world"
6 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Add new mime types for use in respond_to blocks:
6 | # Mime::Type.register "text/richtext", :rtf
7 |
--------------------------------------------------------------------------------
/lib/apartment/active_record/internal_metadata.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class InternalMetadata < ActiveRecord::Base # :nodoc:
4 | class << self
5 | def table_exists?
6 | connection.table_exists?(table_name)
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/spec/dummy/app/models/user_with_tenant_model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/model'
4 |
5 | class UserWithTenantModel < ApplicationRecord
6 | include Apartment::Model
7 |
8 | self.table_name = 'users'
9 | # Dummy models
10 | end
11 |
--------------------------------------------------------------------------------
/spec/support/config.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'yaml'
4 |
5 | module Apartment
6 | module Test
7 | def self.config
8 | @config ||= YAML.safe_load(ERB.new(File.read('spec/config/database.yml')).result)
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | .bundle
3 | *.lock
4 | gemfiles/*.lock
5 | gemfiles/vendor
6 | pkg/*
7 | *.log
8 | .idea
9 | *.sw[pno]
10 | spec/config/database.yml
11 | spec/dummy/config/database.yml
12 | cookbooks
13 | tmp
14 | spec/dummy/db/*.sqlite3
15 | .DS_Store
16 | .claude/
17 | .mcp.json
18 |
--------------------------------------------------------------------------------
/lib/apartment/active_record/schema_migration.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveRecord
4 | class SchemaMigration # :nodoc:
5 | class << self
6 | def table_exists?
7 | connection.table_exists?(table_name)
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Add new mime types for use in respond_to blocks:
6 | # Mime::Type.register "text/richtext", :rtf
7 | # Mime::Type.register_alias "text/html", :iphone
8 |
--------------------------------------------------------------------------------
/spec/dummy/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Dummy
5 | <%= stylesheet_link_tag :all %>
6 | <%= javascript_include_tag :defaults %>
7 | <%= csrf_meta_tag %>
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Configure sensitive parameters which will be filtered from the log file.
6 | Rails.application.config.filter_parameters += [:password]
7 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/boot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Set up gems listed in the Gemfile.
4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__)
5 |
6 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
7 | $LOAD_PATH.unshift(File.expand_path('../../../lib', __dir__))
8 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Add your own tasks in files placed in lib/tasks ending in .rake,
4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
5 |
6 | require File.expand_path('config/application', __dir__)
7 |
8 | Rails.application.load_tasks
9 |
--------------------------------------------------------------------------------
/spec/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Add your own tasks in files placed in lib/tasks ending in .rake,
4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
5 |
6 | require File.expand_path('config/application', __dir__)
7 | require 'rake'
8 |
9 | Dummy::Application.load_tasks
10 |
--------------------------------------------------------------------------------
/spec/dummy/config/boot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rubygems'
4 |
5 | gemfile = File.expand_path('../../../Gemfile', __dir__)
6 |
7 | if File.exist?(gemfile)
8 | ENV['BUNDLE_GEMFILE'] = gemfile
9 | require 'bundler'
10 | Bundler.setup
11 | end
12 |
13 | $LOAD_PATH.unshift(File.expand_path('../../../lib', __dir__))
14 |
--------------------------------------------------------------------------------
/lib/generators/apartment/install/install_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Apartment
4 | class InstallGenerator < Rails::Generators::Base
5 | source_root File.expand_path('templates', __dir__)
6 |
7 | def copy_files
8 | template('apartment.rb', File.join('config', 'initializers', 'apartment.rb'))
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/dummy/script/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # This command will automatically be run when you run "rails" with Rails 3 gems installed
5 | # from the root of your application.
6 |
7 | APP_PATH = File.expand_path('../config/application', __dir__)
8 | require File.expand_path('../config/boot', __dir__)
9 | require 'rails/commands'
10 |
--------------------------------------------------------------------------------
/spec/dummy/db/migrate/20111202022214_create_table_books.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateTableBooks < ActiveRecord::Migration[4.2]
4 | def up
5 | create_table(:books) do |t|
6 | t.string(:name)
7 | t.integer(:pages)
8 | t.datetime(:published)
9 | end
10 | end
11 |
12 | def down
13 | drop_table(:books)
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/spec/dummy/db/migrate/20180415260934_create_public_tokens.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreatePublicTokens < ActiveRecord::Migration[4.2]
4 | def up
5 | create_table(:public_tokens) do |t|
6 | t.string(:token)
7 | t.integer(:user_id, foreign_key: true)
8 | end
9 | end
10 |
11 | def down
12 | drop_table(:public_tokens)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/apartment/adapters/postgis_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # handle postgis adapter as if it were postgresql,
4 | # only override the adapter_method used for initialization
5 | require 'apartment/adapters/postgresql_adapter'
6 |
7 | module Apartment
8 | module Tenant
9 | def self.postgis_adapter(config)
10 | postgresql_adapter(config)
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/spec/config/mysql.yml.erb:
--------------------------------------------------------------------------------
1 | connections:
2 | mysql:
3 | adapter: mysql2
4 | database: apartment_mysql_test
5 | username: root
6 | min_messages: WARNING
7 | host: 127.0.0.1
8 | port: 3306
9 | <% if defined?(JRUBY_VERSION) %>
10 | driver: com.mysql.cj.jdbc.Driver
11 | url: jdbc:mysql://localhost:3306/apartment_mysql_test
12 | timeout: 5000
13 | pool: 5
14 | <% end %>
15 |
--------------------------------------------------------------------------------
/Guardfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # A sample Guardfile
4 | # More info at https://github.com/guard/guard#readme
5 |
6 | guard :rspec do
7 | watch(%r{^spec/.+_spec\.rb$})
8 | watch(%r{^lib/apartment/(.+)\.rb$}) { |m| "spec/unit/#{m[1]}_spec.rb" }
9 | watch(%r{^lib/apartment/(.+)\.rb$}) { |m| "spec/integration/#{m[1]}_spec.rb" }
10 | watch('spec/spec_helper.rb') { 'spec' }
11 | end
12 |
--------------------------------------------------------------------------------
/lib/apartment/adapters/jdbc_mysql_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/adapters/abstract_jdbc_adapter'
4 |
5 | module Apartment
6 | module Tenant
7 | def self.jdbc_mysql_adapter(config)
8 | Adapters::JDBCMysqlAdapter.new(config)
9 | end
10 | end
11 |
12 | module Adapters
13 | class JDBCMysqlAdapter < AbstractJDBCAdapter
14 | def reset_on_connection_exception?
15 | true
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Steps to reproduce
2 |
3 | ## Expected behavior
4 |
5 | ## Actual behavior
6 |
7 | ## System configuration
8 |
9 |
10 |
11 | * Database: (Tell us what database and its version you use.)
12 |
13 | * Apartment version:
14 |
15 | * Apartment config (in `config/initializers/apartment.rb` or so):
16 |
17 | * `use_schemas`: (`true` or `false`)
18 |
19 | * Rails (or ActiveRecord) version:
20 |
21 | * Ruby version:
22 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Add new inflection rules using the following format
6 | # (all these examples are active by default):
7 | # ActiveSupport::Inflector.inflections do |inflect|
8 | # inflect.plural /^(ox)$/i, '\1en'
9 | # inflect.singular /^(ox)en/i, '\1'
10 | # inflect.irregular 'person', 'people'
11 | # inflect.uncountable %w( fish sheep )
12 | # end
13 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/assets.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Version of your assets, change this if you want to expire all your assets.
6 | Rails.application.config.assets.version = '1.0'
7 |
8 | # Precompile additional assets.
9 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
10 | # Rails.application.config.assets.precompile += %w( search.js )
11 |
--------------------------------------------------------------------------------
/spec/config/postgresql.yml.erb:
--------------------------------------------------------------------------------
1 | connections:
2 | postgresql:
3 | adapter: postgresql
4 | database: apartment_postgresql_test
5 | username: postgres
6 | min_messages: WARNING
7 | host: localhost
8 | port: 5432
9 | <% if defined?(JRUBY_VERSION) %>
10 | driver: org.postgresql.Driver
11 | url: jdbc:postgresql://localhost:5432/apartment_postgresql_test
12 | timeout: 5000
13 | pool: 5
14 | <% else %>
15 | schema_search_path: public
16 | password:
17 | <% end %>
18 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
7 |
8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
9 | # Rails.backtrace_cleaner.remove_silencers!
10 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/session_store.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | Dummy::Application.config.session_store(:cookie_store, key: '_dummy_session')
6 |
7 | # Use the database for sessions instead of the cookie-based default,
8 | # which shouldn't be used to store highly confidential information
9 | # (create the session table with "rails generate session_migration")
10 | # Dummy::Application.config.session_store :active_record_store
11 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
7 |
8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
9 | # Rails.backtrace_cleaner.remove_silencers!
10 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'http://rubygems.org'
4 |
5 | gemspec
6 |
7 | gem 'appraisal', '~> 2.3'
8 | gem 'bundler', '< 3.0'
9 | gem 'pry', '~> 0.13'
10 | gem 'rake', '< 14.0'
11 | gem 'rspec', '~> 3.10'
12 | gem 'rspec_junit_formatter', '~> 0.4'
13 | gem 'rspec-rails', '>= 6.1.0', '< 8.1'
14 | gem 'rubocop', '~> 1.12'
15 | gem 'rubocop-performance', '~> 1.10'
16 | gem 'rubocop-rails', '~> 2.10'
17 | gem 'rubocop-rake', '~> 0.5'
18 | gem 'rubocop-rspec', '~> 3.1'
19 | gem 'rubocop-thread_safety', '~> 0.4'
20 | gem 'simplecov', require: false
21 |
--------------------------------------------------------------------------------
/lib/apartment/adapters/abstract_jdbc_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/adapters/abstract_adapter'
4 |
5 | module Apartment
6 | module Adapters
7 | # JDBC Abstract adapter
8 | class AbstractJDBCAdapter < AbstractAdapter
9 | private
10 |
11 | def multi_tenantify_with_tenant_db_name(config, tenant)
12 | config[:url] = "#{config[:url].gsub(%r{(\S+)/.+$}, '\1')}/#{environmentify(tenant)}"
13 | end
14 |
15 | def rescue_from
16 | ActiveRecord::JDBCError
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/spec/dummy_engine/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # This command will automatically be run when you run "rails" with Rails 4 gems installed
5 | # from the root of your application.
6 |
7 | ENGINE_ROOT = File.expand_path('..', __dir__)
8 | ENGINE_PATH = File.expand_path('../lib/dummy_engine/engine', __dir__)
9 |
10 | # Set up gems listed in the Gemfile.
11 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
12 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
13 |
14 | require 'rails/all'
15 | require 'rails/engine/commands'
16 |
--------------------------------------------------------------------------------
/lib/apartment/elevators/first_subdomain.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/elevators/subdomain'
4 |
5 | module Apartment
6 | module Elevators
7 | # Provides a rack based tenant switching solution based on the first subdomain
8 | # of a given domain name.
9 | # eg:
10 | # - example1.domain.com => example1
11 | # - example2.something.domain.com => example2
12 | class FirstSubdomain < Subdomain
13 | def parse_tenant_name(request)
14 | super.split('.')[0] unless super.nil?
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/.github/workflows/rubocop.yml:
--------------------------------------------------------------------------------
1 | name: Rubocop
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | types: [opened, synchronize, reopened]
8 | release:
9 | types: [published]
10 |
11 | jobs:
12 | rubocop:
13 | name: runner / rubocop
14 | runs-on: ubuntu-latest
15 | env:
16 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_7_2_postgresql.gemfile
17 | steps:
18 | - uses: actions/checkout@v4
19 | - uses: ruby/setup-ruby@v1
20 | with:
21 | bundler-cache: true
22 | - name: Rubocop
23 | run: "bundle exec rubocop"
--------------------------------------------------------------------------------
/gemfiles/rails_7_0_mysql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.0.0"
20 | gem "mysql2", "~> 0.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_0_postgresql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.0.0"
20 | gem "pg", "~> 1.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_0_sqlite3.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.0.0"
20 | gem "sqlite3", "~> 1.4"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_1_mysql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.1.0"
20 | gem "mysql2", "~> 0.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_1_postgresql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.1.0"
20 | gem "pg", "~> 1.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_1_sqlite3.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.1.0"
20 | gem "sqlite3", "~> 2.1"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_2_mysql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.2.0"
20 | gem "mysql2", "~> 0.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_2_postgresql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.2.0"
20 | gem "pg", "~> 1.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_2_sqlite3.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.2.0"
20 | gem "sqlite3", "~> 2.1"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_8_0_mysql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 8.0.0"
20 | gem "mysql2", "~> 0.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_8_0_postgresql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 8.0.0"
20 | gem "pg", "~> 1.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_8_0_sqlite3.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 8.0.0"
20 | gem "sqlite3", "~> 2.1"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_8_1_mysql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 8.1.0"
20 | gem "mysql2", "~> 0.5"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_8_1_postgresql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 8.1.0"
20 | gem "pg", "~> 1.6.0"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_8_1_sqlite3.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 8.1.0"
20 | gem "sqlite3", "~> 2.8"
21 |
22 | gemspec path: "../"
23 |
--------------------------------------------------------------------------------
/lib/apartment/console.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | def st(schema_name = nil)
4 | if schema_name.nil?
5 | tenant_list.each { |t| puts t }
6 |
7 | elsif tenant_list.include?(schema_name)
8 | Apartment::Tenant.switch!(schema_name)
9 | else
10 | puts "Tenant #{schema_name} is not part of the tenant list"
11 |
12 | end
13 | end
14 |
15 | def tenant_list
16 | tenant_list = [Apartment.default_tenant]
17 | tenant_list += Apartment.tenant_names
18 | tenant_list.uniq
19 | end
20 |
21 | def tenant_info_msg
22 | puts "Available Tenants: #{tenant_list}\n"
23 | puts "Use `st 'tenant'` to switch tenants & `tenant_list` to see list\n"
24 | end
25 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # This file contains settings for ActionController::ParamsWrapper which
6 | # is enabled by default.
7 |
8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
9 | ActiveSupport.on_load(:action_controller) do
10 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters)
11 | end
12 |
13 | # To enable root element in JSON for ActiveRecord objects.
14 | # ActiveSupport.on_load(:active_record) do
15 | # self.include_root_in_json = true
16 | # end
17 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/secret_token.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Your secret key for verifying the integrity of signed cookies.
6 | # If you change this key, all old signed cookies will become invalid!
7 | # Make sure the secret is at least 30 characters and all random,
8 | # no regular words or you'll be exposed to dictionary attacks.
9 |
10 | # rubocop:disable Layout/LineLength
11 | Dummy::Application.config.secret_token = '7d33999a86884f74c897c98ecca4277090b69e9f23df8d74bcadd57435320a7a16de67966f9b69d62e7d5ec553bd2febbe64c721e05bc1bc1e82c7a7d2395201'
12 | # rubocop:enable Layout/LineLength
13 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite version 3.x
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem 'sqlite3'
6 | #
7 | default: &default
8 | adapter: sqlite3
9 | pool: 5
10 | timeout: 5000
11 |
12 | development:
13 | <<: *default
14 | database: db/development.sqlite3
15 |
16 | # Warning: The database defined as "test" will be erased and
17 | # re-generated from your development database when you run "rake".
18 | # Do not set this db to the same as development or production.
19 | test:
20 | <<: *default
21 | database: db/test.sqlite3
22 |
23 | production:
24 | <<: *default
25 | database: db/production.sqlite3
26 |
--------------------------------------------------------------------------------
/.github/workflows/gem-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to RubyGems
2 |
3 | on:
4 | push:
5 | branches: [ 'main' ]
6 |
7 | jobs:
8 | release:
9 | name: Build + Publish
10 | runs-on: ubuntu-latest
11 | environment: production
12 | permissions:
13 | id-token: write # Required for trusted publishing to RubyGems.org
14 | contents: write # Required for rake release to push the release tag
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Set up Ruby
19 | uses: ruby/setup-ruby@v1
20 | with:
21 | bundler-cache: true
22 | ruby-version: .ruby-version
23 | - name: Publish to RubyGems
24 | uses: rubygems/release-gem@v1
25 |
--------------------------------------------------------------------------------
/spec/dummy_engine/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | # Declare your gem's dependencies in dummy_engine.gemspec.
6 | # Bundler will treat runtime dependencies like base dependencies, and
7 | # development dependencies will be added by default to the :development group.
8 | gemspec
9 |
10 | # Declare any dependencies that are still in development here instead of in
11 | # your gemspec. These might include edge Rails or gems from your path or
12 | # Git. Remember to move these dependencies to your gemspec before releasing
13 | # your gem to rubygems.org.
14 |
15 | # To use debugger
16 | # gem 'debugger'
17 | gem 'ros-apartment', require: 'apartment', path: '../../'
18 |
--------------------------------------------------------------------------------
/lib/apartment/active_record/postgres/schema_dumper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This patch prevents `create_schema` from being added to db/schema.rb as schemas are managed by Apartment
4 | # not ActiveRecord like they would be in a vanilla Rails setup.
5 |
6 | require 'active_record/connection_adapters/abstract/schema_dumper'
7 | require 'active_record/connection_adapters/postgresql/schema_dumper'
8 |
9 | module ActiveRecord
10 | module ConnectionAdapters
11 | module PostgreSQL
12 | class SchemaDumper
13 | alias _original_schemas schemas
14 | def schemas(stream)
15 | _original_schemas(stream) unless Apartment.use_schemas
16 | end
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/apartment/elevators/domain.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/elevators/generic'
4 |
5 | module Apartment
6 | module Elevators
7 | # Provides a rack based tenant switching solution based on domain
8 | # Assumes that tenant name should match domain
9 | # Parses request host for second level domain, ignoring www
10 | # eg. example.com => example
11 | # www.example.bc.ca => example
12 | # a.example.bc.ca => a
13 | #
14 | #
15 | class Domain < Generic
16 | def parse_tenant_name(request)
17 | return nil if request.host.blank?
18 |
19 | request.host.match(/(?:www\.)?(?[^.]*)/)['sld']
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/apartment/adapters/trilogy_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/adapters/mysql2_adapter'
4 |
5 | module Apartment
6 | # Helper module to decide wether to use trilogy adapter or trilogy adapter with schemas
7 | module Tenant
8 | def self.trilogy_adapter(config)
9 | if Apartment.use_schemas
10 | Adapters::TrilogySchemaAdapter.new(config)
11 | else
12 | Adapters::TrilogyAdapter.new(config)
13 | end
14 | end
15 | end
16 |
17 | module Adapters
18 | class TrilogyAdapter < Mysql2Adapter
19 | protected
20 |
21 | def rescue_from
22 | Trilogy::Error
23 | end
24 | end
25 |
26 | class TrilogySchemaAdapter < Mysql2SchemaAdapter
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # To learn more, please read the Rails Internationalization guide
20 | # available at http://guides.rubyonrails.org/i18n.html.
21 |
22 | en:
23 | hello: "Hello world"
24 |
--------------------------------------------------------------------------------
/lib/apartment/elevators/host_hash.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/elevators/generic'
4 |
5 | module Apartment
6 | module Elevators
7 | # Provides a rack based tenant switching solution based on hosts
8 | # Uses a hash to find the corresponding tenant name for the host
9 | #
10 | class HostHash < Generic
11 | def initialize(app, hash = {}, processor = nil)
12 | super(app, processor)
13 | @hash = hash
14 | end
15 |
16 | def parse_tenant_name(request)
17 | unless @hash.key?(request.host)
18 | raise(TenantNotFound,
19 | "Cannot find tenant for host #{request.host}")
20 | end
21 |
22 | @hash[request.host]
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Add new inflection rules using the following format. Inflections
6 | # are locale specific, and you may define rules for as many different
7 | # locales as you wish. All of these examples are active by default:
8 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
9 | # inflect.plural /^(ox)$/i, '\1en'
10 | # inflect.singular /^(ox)en/i, '\1'
11 | # inflect.irregular 'person', 'people'
12 | # inflect.uncountable %w( fish sheep )
13 | # end
14 |
15 | # These inflection rules are supported but not enabled by default:
16 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
17 | # inflect.acronym 'RESTful'
18 | # end
19 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_0_jdbc_mysql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.0.0"
20 |
21 | platforms :jruby do
22 | gem "activerecord-jdbc-adapter", "~> 70.0"
23 | gem "activerecord-jdbcmysql-adapter", "~> 70.0"
24 | gem "jdbc-mysql"
25 | end
26 |
27 | gemspec path: "../"
28 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_0_jdbc_sqlite3.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.0.0"
20 |
21 | platforms :jruby do
22 | gem "activerecord-jdbc-adapter", "~> 70.0"
23 | gem "activerecord-jdbcsqlite3-adapter", "~> 70.0"
24 | gem "jdbc-sqlite3"
25 | end
26 |
27 | gemspec path: "../"
28 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_0_jdbc_postgresql.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal", "~> 2.3"
6 | gem "bundler", "< 3.0"
7 | gem "pry", "~> 0.13"
8 | gem "rake", "< 14.0"
9 | gem "rspec", "~> 3.10"
10 | gem "rspec_junit_formatter", "~> 0.4"
11 | gem "rspec-rails", ">= 6.1.0", "< 8.1"
12 | gem "rubocop", "~> 1.12"
13 | gem "rubocop-performance", "~> 1.10"
14 | gem "rubocop-rails", "~> 2.10"
15 | gem "rubocop-rake", "~> 0.5"
16 | gem "rubocop-rspec", "~> 3.1"
17 | gem "rubocop-thread_safety", "~> 0.4"
18 | gem "simplecov", require: false
19 | gem "rails", "~> 7.0.0"
20 |
21 | platforms :jruby do
22 | gem "activerecord-jdbc-adapter", "~> 70.0"
23 | gem "activerecord-jdbcpostgresql-adapter", "~> 70.0"
24 | gem "jdbc-postgres"
25 | end
26 |
27 | gemspec path: "../"
28 |
--------------------------------------------------------------------------------
/spec/adapters/jdbc_mysql_adapter_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if defined?(JRUBY_VERSION) && ENV['DATABASE_ENGINE'] == 'mysql'
4 |
5 | require 'spec_helper'
6 | require 'apartment/adapters/jdbc_mysql_adapter'
7 |
8 | describe Apartment::Adapters::JDBCMysqlAdapter, database: :mysql do
9 | subject(:adapter) { Apartment::Tenant.adapter }
10 |
11 | def tenant_names
12 | ActiveRecord::Base.connection.execute('SELECT SCHEMA_NAME FROM information_schema.schemata').pluck('SCHEMA_NAME')
13 | end
14 |
15 | let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } }
16 |
17 | it_behaves_like 'a generic apartment adapter callbacks'
18 | it_behaves_like 'a generic apartment adapter'
19 | it_behaves_like 'a connection based apartment adapter'
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/spec/dummy/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
The change you wanted was rejected.
23 |
Maybe you tried to change something you didn't have access to.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/spec/dummy/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
The page you were looking for doesn't exist.
23 |
You may have mistyped the address or the page may have moved.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/spec/dummy/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
We're sorry, but something went wrong.
23 |
We've been notified about this issue and we'll take a look at it shortly.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/spec/schemas/v1.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # This file is auto-generated from the current state of the database. Instead
3 | # of editing this file, please use the migrations feature of Active Record to
4 | # incrementally modify your database, and then regenerate this schema definition.
5 | #
6 | # Note that this schema.rb definition is the authoritative source for your
7 | # database schema. If you need to create the application database on another
8 | # system, you should be using db:schema:load, not running all the migrations
9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
10 | # you'll amass, the slower it'll run and the greater likelihood for issues).
11 | #
12 | # It's strongly recommended to check this file into your version control system.
13 |
14 | ActiveRecord::Schema.define(version: 0) do
15 | end
16 |
--------------------------------------------------------------------------------
/spec/unit/elevators/first_subdomain_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'apartment/elevators/first_subdomain'
5 |
6 | describe Apartment::Elevators::FirstSubdomain do
7 | describe 'subdomain' do
8 | subject { described_class.new('test').parse_tenant_name(request) }
9 |
10 | let(:request) { double(:request, host: "#{subdomain}.example.com") }
11 |
12 | context 'when one subdomain' do
13 | let(:subdomain) { 'test' }
14 |
15 | it { is_expected.to(eq('test')) }
16 | end
17 |
18 | context 'when nested subdomains' do
19 | let(:subdomain) { 'test1.test2' }
20 |
21 | it { is_expected.to(eq('test1')) }
22 | end
23 |
24 | context 'when no subdomain' do
25 | let(:subdomain) { nil }
26 |
27 | it { is_expected.to(be_nil) }
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/apartment/elevators/generic.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rack/request'
4 | require 'apartment/tenant'
5 |
6 | module Apartment
7 | module Elevators
8 | # Provides a rack based tenant switching solution based on request
9 | #
10 | class Generic
11 | def initialize(app, processor = nil)
12 | @app = app
13 | @processor = processor || method(:parse_tenant_name)
14 | end
15 |
16 | def call(env)
17 | request = Rack::Request.new(env)
18 |
19 | database = @processor.call(request)
20 |
21 | if database
22 | Apartment::Tenant.switch(database) { @app.call(env) }
23 | else
24 | @app.call(env)
25 | end
26 | end
27 |
28 | def parse_tenant_name(_request)
29 | raise('Override')
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/spec/dummy_engine/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | begin
4 | require('bundler/setup')
5 | rescue LoadError
6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7 | end
8 |
9 | require 'rdoc/task'
10 |
11 | RDoc::Task.new(:rdoc) do |rdoc|
12 | rdoc.rdoc_dir = 'rdoc'
13 | rdoc.title = 'DummyEngine'
14 | rdoc.options << '--line-numbers'
15 | rdoc.rdoc_files.include('README.rdoc')
16 | rdoc.rdoc_files.include('lib/**/*.rb')
17 | end
18 |
19 | APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
20 | load 'rails/tasks/engine.rake'
21 |
22 | Bundler::GemHelper.install_tasks
23 |
24 | require 'rake/testtask'
25 |
26 | Rake::TestTask.new(:test) do |t|
27 | t.libs << 'lib'
28 | t.libs << 'test'
29 | t.pattern = 'test/**/*_test.rb'
30 | t.verbose = false
31 | end
32 |
33 | task default: :test
34 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Repository Guidelines for AI Agents
2 |
3 | This repo uses `CLAUDE.md` files as authoritative contributor guides for all AI assistants.
4 |
5 | ## Reading Order
6 |
7 | 1. **Read `/CLAUDE.md` (root) first** - project-wide architecture, patterns, design decisions
8 | 2. **Read directory-specific `CLAUDE.md`** - check the directory you're modifying and its parents
9 | 3. **Nested files override root** on conflicts (per AGENTS.md spec)
10 |
11 | Nested `CLAUDE.md` files exist in `lib/apartment/`, `lib/apartment/adapters/`, `lib/apartment/elevators/`, `lib/apartment/tasks/`, and `spec/`.
12 |
13 | ## Key Principle
14 |
15 | Check `CLAUDE.md` before copying patterns from existing code - it documents preferred patterns, design rationale, and known pitfalls.
16 |
17 | ## Adding Documentation
18 |
19 | Update the appropriate `CLAUDE.md` rather than this file. This file exists only as a pointer.
20 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require File.expand_path('boot', __dir__)
4 |
5 | require 'rails/all'
6 |
7 | Bundler.require(*Rails.groups)
8 | require 'dummy_engine'
9 |
10 | module Dummy
11 | class Application < Rails::Application
12 | # Settings in config/environments/* take precedence over those specified here.
13 | # Application configuration should go into files in config/initializers
14 | # -- all .rb files in that directory are automatically loaded.
15 |
16 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
17 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
18 | # config.time_zone = 'Central Time (US & Canada)'
19 |
20 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
21 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
22 | # config.i18n.default_locale = :de
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/secrets.yml:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Your secret key is used for verifying the integrity of signed cookies.
4 | # If you change this key, all old signed cookies will become invalid!
5 |
6 | # Make sure the secret is at least 30 characters and all random,
7 | # no regular words or you'll be exposed to dictionary attacks.
8 | # You can use `rake secret` to generate a secure secret key.
9 |
10 | # Make sure the secrets in this file are kept private
11 | # if you're sharing your code publicly.
12 |
13 | development:
14 | secret_key_base: bb62b819b585a74e69c797f9d03d5a004d8fe82a8e7a7da6fa2f7923030713b7b087c12cc7a918e71073c38afb343f7223d22ba3f1b223b7e76dbf8d5b65fa2c
15 |
16 | test:
17 | secret_key_base: 67945d3b189c71dffef98de2bb7c14d6fb059679c115ca3cddf65c88babe130afe4d583560d0e308b017dd76ce305bef4159d876de9fd893952d9cbf269c8476
18 |
19 | # Do not keep production secrets in the repository,
20 | # instead read values from the environment.
21 | production:
22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
23 |
--------------------------------------------------------------------------------
/spec/dummy/db/migrate/20110613152810_create_dummy_models.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateDummyModels < ActiveRecord::Migration[4.2]
4 | def self.up
5 | create_table(:companies) do |t|
6 | t.boolean(:dummy)
7 | t.string(:database)
8 | end
9 |
10 | create_table(:users) do |t|
11 | t.string(:name)
12 | t.datetime(:birthdate)
13 | t.string(:sex)
14 | end
15 |
16 | create_table(:delayed_jobs) do |t|
17 | t.integer(:priority, default: 0)
18 | t.integer(:attempts, default: 0)
19 | t.text(:handler)
20 | t.text(:last_error)
21 | t.datetime(:run_at)
22 | t.datetime(:locked_at)
23 | t.datetime(:failed_at)
24 | t.string(:locked_by)
25 | t.datetime(:created_at)
26 | t.datetime(:updated_at)
27 | t.string(:queue)
28 | end
29 |
30 | add_index('delayed_jobs', %w[priority run_at], name: 'delayed_jobs_priority')
31 | end
32 |
33 | def self.down
34 | drop_table(:companies)
35 | drop_table(:users)
36 | drop_table(:delayed_jobs)
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/spec/integration/use_within_an_engine_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | describe 'using apartment within an engine' do
4 | before do
5 | engine_path = Pathname.new(File.expand_path('../dummy_engine', __dir__))
6 | require engine_path.join('test/dummy/config/application')
7 | @rake = Rake::Application.new
8 | Rake.application = @rake
9 | stub_const 'APP_RAKEFILE', engine_path.join('test/dummy/Rakefile')
10 | load 'rails/tasks/engine.rake'
11 | end
12 |
13 | it 'sucessfully runs rake db:migrate in the engine root' do
14 | expect { Rake::Task['db:migrate'].invoke }.not_to(raise_error)
15 | end
16 |
17 | it 'sucessfully runs rake app:db:migrate in the engine root' do
18 | expect { Rake::Task['app:db:migrate'].invoke }.not_to(raise_error)
19 | end
20 |
21 | context 'when Apartment.db_migrate_tenants is false' do
22 | it 'does not enhance tasks' do
23 | Apartment.db_migrate_tenants = false
24 | expect(Apartment::RakeTaskEnhancer).not_to(receive(:enhance_task).with('db:migrate'))
25 | Rake::Task['db:migrate'].invoke
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/unit/elevators/domain_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'apartment/elevators/domain'
5 |
6 | describe Apartment::Elevators::Domain do
7 | subject(:elevator) { described_class.new(proc {}) }
8 |
9 | describe '#parse_tenant_name' do
10 | it 'parses the host for a domain name' do
11 | request = ActionDispatch::Request.new('HTTP_HOST' => 'example.com')
12 | expect(elevator.parse_tenant_name(request)).to(eq('example'))
13 | end
14 |
15 | it 'ignores a www prefix and domain suffix' do
16 | request = ActionDispatch::Request.new('HTTP_HOST' => 'www.example.bc.ca')
17 | expect(elevator.parse_tenant_name(request)).to(eq('example'))
18 | end
19 |
20 | it 'returns nil if there is no host' do
21 | request = ActionDispatch::Request.new('HTTP_HOST' => '')
22 | expect(elevator.parse_tenant_name(request)).to(be_nil)
23 | end
24 | end
25 |
26 | describe '#call' do
27 | it 'switches to the proper tenant' do
28 | expect(Apartment::Tenant).to(receive(:switch).with('example'))
29 |
30 | elevator.call('HTTP_HOST' => 'www.example.com')
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Dummy::Application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb
5 |
6 | # In the development environment your application's code is reloaded on
7 | # every request. This slows down response time but is perfect for development
8 | # since you don't have to restart the webserver when you make code changes.
9 | config.cache_classes = false
10 |
11 | config.eager_load = false
12 |
13 | # Log error messages when you accidentally call methods on nil.
14 | config.whiny_nils = true
15 |
16 | # Show full error reports and disable caching
17 | config.consider_all_requests_local = true
18 | config.action_view.debug_rjs = true
19 | config.action_controller.perform_caching = false
20 |
21 | # Don't care if the mailer can't send
22 | config.action_mailer.raise_delivery_errors = false
23 |
24 | # Print deprecation notices to the Rails logger
25 | config.active_support.deprecation = :log
26 |
27 | # Only use best-standards-support built into browsers
28 | config.action_dispatch.best_standards_support = :builtin
29 | end
30 |
--------------------------------------------------------------------------------
/lib/apartment/elevators/host.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/elevators/generic'
4 |
5 | module Apartment
6 | module Elevators
7 | # Provides a rack based tenant switching solution based on the host
8 | # Assumes that tenant name should match host
9 | # Strips/ignores first subdomains in ignored_first_subdomains
10 | # eg. example.com => example.com
11 | # www.example.bc.ca => www.example.bc.ca
12 | # if ignored_first_subdomains = ['www']
13 | # www.example.bc.ca => example.bc.ca
14 | # www.a.b.c.d.com => a.b.c.d.com
15 | #
16 | class Host < Generic
17 | def self.ignored_first_subdomains
18 | @ignored_first_subdomains ||= []
19 | end
20 |
21 | # rubocop:disable Style/TrivialAccessors
22 | def self.ignored_first_subdomains=(arg)
23 | @ignored_first_subdomains = arg
24 | end
25 | # rubocop:enable Style/TrivialAccessors
26 |
27 | def parse_tenant_name(request)
28 | return nil if request.host.blank?
29 |
30 | parts = request.host.split('.')
31 | self.class.ignored_first_subdomains.include?(parts[0]) ? parts.drop(1).join('.') : request.host
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/spec/support/requirements.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Apartment
4 | module Spec
5 | #
6 | # Define the interface methods required to
7 | # use an adapter shared example
8 | #
9 | #
10 | module AdapterRequirements
11 | extend ActiveSupport::Concern
12 |
13 | included do
14 | before do
15 | subject.create(db1)
16 | subject.create(db2)
17 | end
18 |
19 | after do
20 | # Reset before dropping (can't drop a db you're connected to)
21 | subject.reset
22 |
23 | # sometimes we manually drop these schemas in testing, don't care if
24 | # we can't drop, hence rescue
25 | begin
26 | subject.drop(db1)
27 | rescue StandardError => _e
28 | true
29 | end
30 |
31 | begin
32 | subject.drop(db2)
33 | rescue StandardError => _e
34 | true
35 | end
36 | end
37 | end
38 |
39 | %w[subject tenant_names default_tenant].each do |method|
40 | next if defined?(method)
41 |
42 | define_method method do
43 | raise "You must define a `#{method}` method in your host group"
44 | end
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/apartment/active_record/connection_handling.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveRecord # :nodoc:
4 | # This is monkeypatching Active Record to ensure that whenever a new connection is established it
5 | # switches to the same tenant as before the connection switching. This problem is more evident when
6 | # using read replica in Rails 6
7 | module ConnectionHandling
8 | if ActiveRecord.version.release <= Gem::Version.new('6.2')
9 | def connected_to_with_tenant(database: nil, role: nil, prevent_writes: false, &blk)
10 | current_tenant = Apartment::Tenant.current
11 |
12 | connected_to_without_tenant(database: database, role: role, prevent_writes: prevent_writes) do
13 | Apartment::Tenant.switch!(current_tenant)
14 | yield(blk)
15 | end
16 | end
17 | else
18 | def connected_to_with_tenant(role: nil, shard: nil, prevent_writes: false, &blk)
19 | current_tenant = Apartment::Tenant.current
20 |
21 | connected_to_without_tenant(role: role, shard: shard, prevent_writes: prevent_writes) do
22 | Apartment::Tenant.switch!(current_tenant)
23 | yield(blk)
24 | end
25 | end
26 | end
27 |
28 | alias connected_to_without_tenant connected_to
29 | alias connected_to connected_to_with_tenant
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/unit/elevators/host_hash_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'apartment/elevators/host_hash'
5 |
6 | describe Apartment::Elevators::HostHash do
7 | subject(:elevator) { described_class.new(proc {}, 'example.com' => 'example_tenant') }
8 |
9 | describe '#parse_tenant_name' do
10 | it 'parses the host for a domain name' do
11 | request = ActionDispatch::Request.new('HTTP_HOST' => 'example.com')
12 | expect(elevator.parse_tenant_name(request)).to(eq('example_tenant'))
13 | end
14 |
15 | it 'raises TenantNotFound exception if there is no host' do
16 | request = ActionDispatch::Request.new('HTTP_HOST' => '')
17 | expect { elevator.parse_tenant_name(request) }.to(raise_error(Apartment::TenantNotFound))
18 | end
19 |
20 | it 'raises TenantNotFound exception if there is no database associated to current host' do
21 | request = ActionDispatch::Request.new('HTTP_HOST' => 'example2.com')
22 | expect { elevator.parse_tenant_name(request) }.to(raise_error(Apartment::TenantNotFound))
23 | end
24 | end
25 |
26 | describe '#call' do
27 | it 'switches to the proper tenant' do
28 | expect(Apartment::Tenant).to(receive(:switch).with('example_tenant'))
29 |
30 | elevator.call('HTTP_HOST' => 'example.com')
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/apartment/model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Apartment
4 | module Model
5 | extend ActiveSupport::Concern
6 |
7 | module ClassMethods
8 | # NOTE: key can either be an array of symbols or a single value.
9 | # E.g. If we run the following query:
10 | # `Setting.find_by(key: 'something', value: 'amazing')` key will have an array of symbols: `[:key, :something]`
11 | # while if we run:
12 | # `Setting.find(10)` key will have the value 'id'
13 | def cached_find_by_statement(key, &block)
14 | # Modifying the cache key to have a reference to the current tenant,
15 | # so the cached statement is referring only to the tenant in which we've
16 | # executed this
17 | cache_key = if key.is_a?(String)
18 | "#{Apartment::Tenant.current}_#{key}"
19 | else
20 | # NOTE: In Rails 6.0.4 we start receiving an ActiveRecord::Reflection::BelongsToReflection
21 | # as the key, which wouldn't work well with an array.
22 | [Apartment::Tenant.current] + Array.wrap(key)
23 | end
24 | cache = @find_by_statement_cache[connection.prepared_statements]
25 | cache.compute_if_absent(cache_key) { ActiveRecord::StatementCache.create(connection, &block) }
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/support/apartment_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Apartment
4 | module Test
5 | # rubocop:disable Style/ModuleFunction
6 | extend self
7 | # rubocop:enable Style/ModuleFunction
8 |
9 | def reset
10 | Apartment.excluded_models = nil
11 | Apartment.use_schemas = nil
12 | Apartment.seed_after_create = nil
13 | Apartment.default_tenant = nil
14 | end
15 |
16 | def next_db
17 | @x ||= 0
18 | format('db%d', db_idx: @x += 1)
19 | end
20 |
21 | def drop_schema(schema)
22 | ActiveRecord::Base.connection.execute("DROP SCHEMA IF EXISTS #{schema} CASCADE")
23 | rescue StandardError => _e
24 | true
25 | end
26 |
27 | # Use this if you don't want to import schema.rb etc... but need the postgres schema to exist
28 | # basically for speed purposes
29 | def create_schema(schema)
30 | ActiveRecord::Base.connection.execute("CREATE SCHEMA #{schema}")
31 | end
32 |
33 | def load_schema(version = 3)
34 | file = File.expand_path("../../schemas/v#{version}.rb", __FILE__)
35 |
36 | silence_warnings { load(file) }
37 | end
38 |
39 | def migrate
40 | ActiveRecord::Migrator.migrate(Rails.root + ActiveRecord::Migrator.migrations_path)
41 | end
42 |
43 | def rollback
44 | ActiveRecord::Migrator.rollback(Rails.root + ActiveRecord::Migrator.migrations_path)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/spec/examples/connection_adapter_examples.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | shared_examples_for 'a connection based apartment adapter' do
6 | include Apartment::Spec::AdapterRequirements
7 |
8 | let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } }
9 |
10 | describe '#init' do
11 | after do
12 | # Apartment::Tenant.init creates per model connection.
13 | # Remove the connection after testing not to unintentionally keep the connection across tests.
14 | Apartment.excluded_models.each do |excluded_model|
15 | excluded_model.constantize.remove_connection
16 | end
17 | end
18 |
19 | it 'processes model exclusions' do
20 | Apartment.configure do |config|
21 | config.excluded_models = ['Company']
22 | end
23 | Apartment::Tenant.init
24 |
25 | expect(Company.connection.object_id).not_to(eq(ActiveRecord::Base.connection.object_id))
26 | end
27 | end
28 |
29 | describe '#drop' do
30 | it 'raises an error for unknown database' do
31 | expect do
32 | subject.drop('unknown_database')
33 | end.to(raise_error(Apartment::TenantNotFound))
34 | end
35 | end
36 |
37 | describe '#switch!' do
38 | it 'raises an error if database is invalid' do
39 | expect do
40 | subject.switch!('unknown_database')
41 | end.to(raise_error(Apartment::TenantNotFound))
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/spec/support/setup.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Apartment
4 | module Spec
5 | module Setup
6 | # rubocop:disable Metrics/AbcSize
7 | def self.included(base)
8 | base.instance_eval do
9 | let(:db1) { Apartment::Test.next_db }
10 | let(:db2) { Apartment::Test.next_db }
11 | let(:connection) { ActiveRecord::Base.connection }
12 |
13 | # This around ensures that we run these hooks before and after
14 | # any before/after hooks defined in individual tests
15 | # Otherwise these actually get run after test defined hooks
16 | around do |example|
17 | def config
18 | db = RSpec.current_example.metadata.fetch(:database, :postgresql)
19 |
20 | Apartment::Test.config['connections'][db.to_s]&.symbolize_keys
21 | end
22 |
23 | # before
24 | Apartment::Tenant.reload!(config)
25 | ActiveRecord::Base.establish_connection(config)
26 |
27 | example.run
28 |
29 | # after
30 | ActiveRecord::Base.connection_handler.clear_all_connections!
31 |
32 | Apartment.excluded_models.each do |model|
33 | klass = model.constantize
34 |
35 | klass.remove_connection
36 | klass.connection_handler.clear_all_connections!
37 | klass.reset_table_name
38 | end
39 | Apartment.reset
40 | Apartment::Tenant.reload!
41 | end
42 | end
43 | end
44 | # rubocop:enable Metrics/AbcSize
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/apartment/log_subscriber.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'active_record/log_subscriber'
4 |
5 | module Apartment
6 | # Custom Log subscriber to include database name and schema name in sql logs
7 | class LogSubscriber < ActiveRecord::LogSubscriber
8 | # NOTE: for some reason, if the method definition is not here, then the custom debug method is not called
9 | # rubocop:disable Lint/UselessMethodDefinition
10 | def sql(event)
11 | super
12 | end
13 | # rubocop:enable Lint/UselessMethodDefinition
14 |
15 | private
16 |
17 | def debug(progname = nil, &)
18 | progname = " #{apartment_log}#{progname}" unless progname.nil?
19 |
20 | super
21 | end
22 |
23 | def apartment_log
24 | database = color("[#{database_name}] ", ActiveSupport::LogSubscriber::MAGENTA, bold: true)
25 | schema = current_search_path
26 | schema = color("[#{schema.tr('"', '')}] ", ActiveSupport::LogSubscriber::YELLOW, bold: true) unless schema.nil?
27 | "#{database}#{schema}"
28 | end
29 |
30 | def current_search_path
31 | if Apartment.connection.respond_to?(:schema_search_path)
32 | Apartment.connection.schema_search_path
33 | else
34 | Apartment::Tenant.current # all others
35 | end
36 | end
37 |
38 | def database_name
39 | db_name = Apartment.connection.raw_connection.try(:db) # PostgreSQL, PostGIS
40 | db_name ||= Apartment.connection.raw_connection.try(:query_options)&.dig(:database) # Mysql
41 | db_name ||= Apartment.connection.current_database # Failover
42 | db_name
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Dummy::Application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb
5 |
6 | # The test environment is used exclusively to run your application's
7 | # test suite. You never need to work with it otherwise. Remember that
8 | # your test database is "scratch space" for the test suite and is wiped
9 | # and recreated between test runs. Don't rely on the data there!
10 | config.cache_classes = true
11 |
12 | config.eager_load = false
13 |
14 | # Show full error reports and disable caching
15 | config.consider_all_requests_local = true
16 | config.action_controller.perform_caching = false
17 |
18 | # Raise exceptions instead of rendering exception templates
19 | config.action_dispatch.show_exceptions = false
20 |
21 | # Disable request forgery protection in test environment
22 | config.action_controller.allow_forgery_protection = false
23 |
24 | # Tell Action Mailer not to deliver emails to the real world.
25 | # The :test delivery method accumulates sent emails in the
26 | # ActionMailer::Base.deliveries array.
27 | config.action_mailer.delivery_method = :test
28 |
29 | # Use SQL instead of Active Record's schema dumper when creating the test database.
30 | # This is necessary if your schema can't be completely dumped by the schema dumper,
31 | # like if you have constraints or database-specific column types
32 | # config.active_record.schema_format = :sql
33 |
34 | # Print deprecation notices to the stderr
35 | config.active_support.deprecation = :stderr
36 | end
37 |
--------------------------------------------------------------------------------
/spec/schemas/v2.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # This file is auto-generated from the current state of the database. Instead
3 | # of editing this file, please use the migrations feature of Active Record to
4 | # incrementally modify your database, and then regenerate this schema definition.
5 | #
6 | # Note that this schema.rb definition is the authoritative source for your
7 | # database schema. If you need to create the application database on another
8 | # system, you should be using db:schema:load, not running all the migrations
9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
10 | # you'll amass, the slower it'll run and the greater likelihood for issues).
11 | #
12 | # It's strongly recommended to check this file into your version control system.
13 |
14 | ActiveRecord::Schema.define(version: 20110613152810) do
15 | create_table 'companies', force: true do |t|
16 | t.boolean 'dummy'
17 | t.string 'database'
18 | end
19 |
20 | create_table 'delayed_jobs', force: true do |t|
21 | t.integer 'priority', default: 0
22 | t.integer 'attempts', default: 0
23 | t.text 'handler'
24 | t.text 'last_error'
25 | t.datetime 'run_at'
26 | t.datetime 'locked_at'
27 | t.datetime 'failed_at'
28 | t.string 'locked_by'
29 | t.datetime 'created_at'
30 | t.datetime 'updated_at'
31 | t.string 'queue'
32 | end
33 |
34 | add_index 'delayed_jobs', ['priority', 'run_at'], name: 'delayed_jobs_priority'
35 |
36 | create_table 'users', force: true do |t|
37 | t.string 'name'
38 | t.datetime 'birthdate'
39 | t.string 'sex'
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # In the development environment your application's code is reloaded on
7 | # every request. This slows down response time but is perfect for development
8 | # since you don't have to restart the web server when you make code changes.
9 | config.cache_classes = false
10 |
11 | # Do not eager load code on boot.
12 | config.eager_load = false
13 |
14 | # Show full error reports and disable caching.
15 | config.consider_all_requests_local = true
16 | config.action_controller.perform_caching = false
17 |
18 | # Don't care if the mailer can't send.
19 | config.action_mailer.raise_delivery_errors = false
20 |
21 | # Print deprecation notices to the Rails logger.
22 | config.active_support.deprecation = :log
23 |
24 | # Raise an error on page load if there are pending migrations.
25 | config.active_record.migration_error = :page_load
26 |
27 | # Debug mode disables concatenation and preprocessing of assets.
28 | # This option may cause significant delays in view rendering with a large
29 | # number of complex assets.
30 | config.assets.debug = true
31 |
32 | # Adds additional error checking when serving assets at runtime.
33 | # Checks for improperly declared sprockets dependencies.
34 | # Raises helpful error messages.
35 | config.assets.raise_runtime_errors = true
36 |
37 | # Raises error for missing translations
38 | # config.action_view.raise_on_missing_translations = true
39 | end
40 |
--------------------------------------------------------------------------------
/spec/adapters/jdbc_postgresql_adapter_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if defined?(JRUBY_VERSION) && ENV['DATABASE_ENGINE'] == 'postgresql'
4 |
5 | require 'spec_helper'
6 | require 'apartment/adapters/jdbc_postgresql_adapter'
7 |
8 | describe Apartment::Adapters::JDBCPostgresqlAdapter, database: :postgresql do
9 | subject(:adapter) { Apartment::Tenant.adapter }
10 |
11 | it_behaves_like 'a generic apartment adapter callbacks'
12 |
13 | context 'when using schemas' do
14 | before { Apartment.use_schemas = true }
15 |
16 | # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test
17 | def tenant_names
18 | ActiveRecord::Base.connection.execute('SELECT nspname FROM pg_namespace;').pluck('nspname')
19 | end
20 |
21 | let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.schema_search_path.delete('"') } }
22 |
23 | it_behaves_like 'a generic apartment adapter'
24 | it_behaves_like 'a schema based apartment adapter'
25 | end
26 |
27 | context 'when using databases' do
28 | before { Apartment.use_schemas = false }
29 |
30 | # Not sure why, but somehow using let(:tenant_names) memoizes for the whole example group, not just each test
31 | def tenant_names
32 | connection.execute('select datname from pg_database;').pluck('datname')
33 | end
34 |
35 | let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } }
36 |
37 | it_behaves_like 'a generic apartment adapter'
38 | it_behaves_like 'a connection based apartment adapter'
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/apartment/custom_console.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative 'console'
4 |
5 | module Apartment
6 | module CustomConsole
7 | begin
8 | require('pry-rails')
9 | rescue LoadError
10 | # rubocop:disable Layout/LineLength
11 | puts '[Failed to load pry-rails] If you want to use Apartment custom prompt you need to add pry-rails to your gemfile'
12 | # rubocop:enable Layout/LineLength
13 | end
14 |
15 | desc = "Includes the current Rails environment and project folder name.\n" \
16 | '[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>'
17 |
18 | prompt_procs = [
19 | proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '>') },
20 | proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '*') },
21 | ]
22 |
23 | if Gem::Version.new(Pry::VERSION) >= Gem::Version.new('0.13')
24 | Pry.config.prompt = Pry::Prompt.new('ros', desc, prompt_procs)
25 | else
26 | Pry::Prompt.add('ros', desc, %w[> *]) do |target_self, nest_level, pry, sep|
27 | prompt_contents(pry, target_self, nest_level, sep)
28 | end
29 | Pry.config.prompt = Pry::Prompt[:ros][:value]
30 | end
31 |
32 | Pry.config.hooks.add_hook(:when_started, 'startup message') do
33 | tenant_info_msg
34 | end
35 |
36 | def self.prompt_contents(pry, target_self, nest_level, sep)
37 | "[#{pry.input_ring.size}] [#{PryRails::Prompt.formatted_env}][#{Apartment::Tenant.current}] " \
38 | "#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \
39 | "#{":#{nest_level}" unless nest_level.zero?}#{sep} "
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/spec/support/contexts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Some shared contexts for specs
4 |
5 | shared_context 'with default schema', :default_tenant do
6 | let(:default_tenant) { Apartment::Test.next_db }
7 |
8 | before do
9 | # create a new tenant using apartment itself instead of Apartment::Test.create_schema
10 | # so the default tenant also have the tables used in tests
11 | Apartment::Tenant.create(default_tenant)
12 | Apartment.default_tenant = default_tenant
13 | end
14 |
15 | after do
16 | # resetting default_tenant so we can drop and any further resets won't try to access droppped schema
17 | Apartment.default_tenant = nil
18 | Apartment::Test.drop_schema(default_tenant)
19 | end
20 | end
21 |
22 | # Some default setup for elevator specs
23 | shared_context 'elevators', :elevator do
24 | let(:company1) { mock_model(Company, database: db1).as_null_object }
25 | let(:company2) { mock_model(Company, database: db2).as_null_object }
26 |
27 | let(:api) { Apartment::Tenant }
28 |
29 | before do
30 | Apartment.reset # reset all config
31 | Apartment.seed_after_create = false
32 | Apartment.use_schemas = true
33 | api.reload!(config)
34 | api.create(db1)
35 | api.create(db2)
36 | end
37 |
38 | after do
39 | api.drop(db1)
40 | api.drop(db2)
41 | end
42 | end
43 |
44 | shared_context 'persistent_schemas', :persistent_schemas do
45 | let(:persistent_schemas) { %w[hstore postgis] }
46 |
47 | before do
48 | persistent_schemas.map { |schema| subject.create(schema) }
49 | Apartment.persistent_schemas = persistent_schemas
50 | end
51 |
52 | after do
53 | Apartment.persistent_schemas = []
54 | persistent_schemas.map { |schema| subject.drop(schema) }
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/spec/examples/generic_adapters_callbacks_examples.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | shared_examples_for 'a generic apartment adapter callbacks' do
6 | # rubocop:disable Lint/ConstantDefinitionInBlock
7 | class MyProc
8 | def self.call(tenant_name); end
9 | end
10 | # rubocop:enable Lint/ConstantDefinitionInBlock
11 |
12 | include Apartment::Spec::AdapterRequirements
13 |
14 | before do
15 | Apartment.prepend_environment = false
16 | Apartment.append_environment = false
17 | end
18 |
19 | describe '#switch!' do
20 | before do
21 | Apartment::Adapters::AbstractAdapter.set_callback(:switch, :before) do
22 | MyProc.call(Apartment::Tenant.current)
23 | end
24 |
25 | Apartment::Adapters::AbstractAdapter.set_callback(:switch, :after) do
26 | MyProc.call(Apartment::Tenant.current)
27 | end
28 |
29 | allow(MyProc).to(receive(:call))
30 | end
31 |
32 | # NOTE: Part of the test setup creates and switches tenants, so we need
33 | # to reset the callbacks to ensure that each test run has the correct
34 | # counts
35 | after do
36 | Apartment::Adapters::AbstractAdapter.reset_callbacks(:switch)
37 | end
38 |
39 | context 'when tenant is nil' do
40 | before do
41 | Apartment::Tenant.switch!(nil)
42 | end
43 |
44 | it 'runs both before and after callbacks' do
45 | expect(MyProc).to(have_received(:call).twice)
46 | end
47 | end
48 |
49 | context 'when tenant is not nil' do
50 | before do
51 | Apartment::Tenant.switch!(db1)
52 | end
53 |
54 | it 'runs both before and after callbacks' do
55 | expect(MyProc).to(have_received(:call).twice)
56 | end
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/ros-apartment.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | $LOAD_PATH << File.expand_path('lib', __dir__)
4 | require 'apartment/version'
5 |
6 | Gem::Specification.new do |s|
7 | s.name = 'ros-apartment'
8 | s.version = Apartment::VERSION
9 |
10 | s.authors = ['Ryan Brunner', 'Brad Robertson', 'Rui Baltazar', 'Mauricio Novelo']
11 | s.summary = 'A Ruby gem for managing database multitenancy. Apartment Gem drop in replacement'
12 | s.description = 'Apartment allows Rack applications to deal with database multitenancy through ActiveRecord'
13 | s.email = ['ryan@influitive.com', 'brad@influitive.com', 'rui.p.baltazar@gmail.com', 'mauricio@campusesp.com']
14 | # Specify which files should be added to the gem when it is released.
15 | # The `git ls-files -z` loads the files in the RubyGem that have been
16 | # added into git.
17 | s.files = Dir.chdir(File.expand_path(__dir__)) do
18 | `git ls-files -z`.split("\x0").reject do |f|
19 | # NOTE: ignore all test related
20 | f.match(%r{^(test|spec|features|documentation|gemfiles|.github)/})
21 | end
22 | end
23 | s.executables = s.files.grep(%r{^bin/}).map { |f| File.basename(f) }
24 | s.require_paths = ['lib']
25 |
26 | s.homepage = 'https://github.com/rails-on-services/apartment'
27 | s.licenses = ['MIT']
28 | s.metadata = {
29 | 'github_repo' => 'ssh://github.com/rails-on-services/apartment',
30 | 'rubygems_mfa_required' => 'true',
31 | }
32 |
33 | s.required_ruby_version = '>= 3.1'
34 |
35 | s.add_dependency('activerecord', '>= 7.0.0', '< 8.2')
36 | s.add_dependency('activesupport', '>= 7.0.0', '< 8.2')
37 | s.add_dependency('parallel', '< 2.0')
38 | s.add_dependency('public_suffix', '>= 2.0.5', '< 7')
39 | s.add_dependency('rack', '>= 1.3.6', '< 4.0')
40 | end
41 |
--------------------------------------------------------------------------------
/spec/schemas/v3.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # This file is auto-generated from the current state of the database. Instead
3 | # of editing this file, please use the migrations feature of Active Record to
4 | # incrementally modify your database, and then regenerate this schema definition.
5 | #
6 | # Note that this schema.rb definition is the authoritative source for your
7 | # database schema. If you need to create the application database on another
8 | # system, you should be using db:schema:load, not running all the migrations
9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
10 | # you'll amass, the slower it'll run and the greater likelihood for issues).
11 | #
12 | # It's strongly recommended to check this file into your version control system.
13 |
14 | ActiveRecord::Schema.define(version: 20111202022214) do
15 | create_table 'books', force: true do |t|
16 | t.string 'name'
17 | t.integer 'pages'
18 | t.datetime 'published'
19 | end
20 |
21 | create_table 'companies', force: true do |t|
22 | t.boolean 'dummy'
23 | t.string 'database'
24 | end
25 |
26 | create_table 'delayed_jobs', force: true do |t|
27 | t.integer 'priority', default: 0
28 | t.integer 'attempts', default: 0
29 | t.text 'handler'
30 | t.text 'last_error'
31 | t.datetime 'run_at'
32 | t.datetime 'locked_at'
33 | t.datetime 'failed_at'
34 | t.string 'locked_by'
35 | t.datetime 'created_at'
36 | t.datetime 'updated_at'
37 | t.string 'queue'
38 | end
39 |
40 | add_index 'delayed_jobs', ['priority', 'run_at'], name: 'delayed_jobs_priority'
41 |
42 | create_table 'users', force: true do |t|
43 | t.string 'name'
44 | t.datetime 'birthdate'
45 | t.string 'sex'
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/spec/unit/elevators/generic_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'apartment/elevators/generic'
5 |
6 | describe Apartment::Elevators::Generic do
7 | # rubocop:disable Lint/ConstantDefinitionInBlock
8 | class MyElevator < described_class
9 | def parse_tenant_name(*)
10 | 'tenant2'
11 | end
12 | end
13 | # rubocop:enable Lint/ConstantDefinitionInBlock
14 |
15 | subject(:elevator) { described_class.new(proc {}) }
16 |
17 | describe '#call' do
18 | it 'calls the processor if given' do
19 | elevator = described_class.new(proc {}, proc { 'tenant1' })
20 |
21 | expect(Apartment::Tenant).to(receive(:switch).with('tenant1'))
22 |
23 | elevator.call('HTTP_HOST' => 'foo.bar.com')
24 | end
25 |
26 | it 'raises if parse_tenant_name not implemented' do
27 | expect do
28 | elevator.call('HTTP_HOST' => 'foo.bar.com')
29 | end.to(raise_error(RuntimeError))
30 | end
31 |
32 | it 'switches to the parsed db_name' do
33 | elevator = MyElevator.new(proc {})
34 |
35 | expect(Apartment::Tenant).to(receive(:switch).with('tenant2'))
36 |
37 | elevator.call('HTTP_HOST' => 'foo.bar.com')
38 | end
39 |
40 | it 'calls the block implementation of `switch`' do
41 | elevator = MyElevator.new(proc {}, proc { 'tenant2' })
42 |
43 | expect(Apartment::Tenant).to(receive(:switch).with('tenant2').and_yield)
44 | elevator.call('HTTP_HOST' => 'foo.bar.com')
45 | end
46 |
47 | it 'does not call `switch` if no database given' do
48 | app = proc {}
49 | elevator = MyElevator.new(app, proc {})
50 |
51 | expect(Apartment::Tenant).not_to(receive(:switch))
52 | expect(app).to(receive(:call))
53 |
54 | elevator.call('HTTP_HOST' => 'foo.bar.com')
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/apartment/adapters/sqlite3_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/adapters/abstract_adapter'
4 |
5 | module Apartment
6 | module Tenant
7 | def self.sqlite3_adapter(config)
8 | Adapters::Sqlite3Adapter.new(config)
9 | end
10 | end
11 |
12 | module Adapters
13 | class Sqlite3Adapter < AbstractAdapter
14 | def initialize(config)
15 | @default_dir = File.expand_path(File.dirname(config[:database]))
16 |
17 | super
18 | end
19 |
20 | def drop(tenant)
21 | unless File.exist?(database_file(tenant))
22 | raise(TenantNotFound,
23 | "The tenant #{environmentify(tenant)} cannot be found.")
24 | end
25 |
26 | File.delete(database_file(tenant))
27 | end
28 |
29 | def current
30 | File.basename(Apartment.connection.instance_variable_get(:@config)[:database], '.sqlite3')
31 | end
32 |
33 | protected
34 |
35 | def connect_to_new(tenant)
36 | return reset if tenant.nil?
37 |
38 | unless File.exist?(database_file(tenant))
39 | raise(TenantNotFound,
40 | "The tenant #{environmentify(tenant)} cannot be found.")
41 | end
42 |
43 | super(database_file(tenant))
44 | end
45 |
46 | def create_tenant(tenant)
47 | if File.exist?(database_file(tenant))
48 | raise(TenantExists,
49 | "The tenant #{environmentify(tenant)} already exists.")
50 | end
51 |
52 | begin
53 | f = File.new(database_file(tenant), File::CREAT)
54 | ensure
55 | f.close
56 | end
57 | end
58 |
59 | private
60 |
61 | def database_file(tenant)
62 | "#{@default_dir}/#{environmentify(tenant)}.sqlite3"
63 | end
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/spec/dummy_engine/config/initializers/apartment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Require whichever elevator you're using below here...
4 | #
5 | # require 'apartment/elevators/generic'
6 | # require 'apartment/elevators/domain'
7 | require 'apartment/elevators/subdomain'
8 |
9 | #
10 | # Apartment Configuration
11 | #
12 | Apartment.configure do |config|
13 | # These models will not be multi-tenanted,
14 | # but remain in the global (public) namespace
15 | #
16 | # An example might be a Customer or Tenant model that stores each tenant information
17 | # ex:
18 | #
19 | # config.excluded_models = %w{Tenant}
20 | #
21 | config.excluded_models = %w[]
22 |
23 | # use postgres schemas?
24 | config.use_schemas = true
25 |
26 | # use raw SQL dumps for creating postgres schemas? (only applies with use_schemas set to true)
27 | # config.use_sql = true
28 |
29 | # configure persistent schemas (E.g. hstore )
30 | # config.persistent_schemas = %w{ hstore }
31 |
32 | # add the Rails environment to database names?
33 | # config.prepend_environment = true
34 | # config.append_environment = true
35 |
36 | # supply list of database names for migrations to run on
37 | # config.tenant_names = lambda{ ToDo_Tenant_Or_User_Model.pluck :database }
38 |
39 | # Specify a connection other than ActiveRecord::Base for apartment to use
40 | # (only needed if your models are using a different connection)
41 | # config.connection_class = ActiveRecord::Base
42 | end
43 |
44 | ##
45 | # Elevator Configuration
46 |
47 | # Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request|
48 | # # TODO: supply generic implementation
49 | # }
50 |
51 | # Rails.application.config.middleware.use Apartment::Elevators::Domain
52 |
53 | Rails.application.config.middleware.use(Apartment::Elevators::Subdomain)
54 |
--------------------------------------------------------------------------------
/lib/apartment/tenant.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'forwardable'
4 |
5 | module Apartment
6 | # The main entry point to Apartment functions
7 | #
8 | module Tenant
9 | extend self
10 | extend Forwardable
11 |
12 | def_delegators :adapter, :create, :drop, :switch, :switch!, :current, :each,
13 | :reset, :init, :set_callback, :seed, :default_tenant, :environmentify
14 |
15 | attr_writer :config
16 |
17 | # Fetch the proper multi-tenant adapter based on Rails config
18 | #
19 | # @return {subclass of Apartment::AbstractAdapter}
20 | #
21 | def adapter
22 | Thread.current[:apartment_adapter] ||= begin
23 | adapter_method = "#{config[:adapter]}_adapter"
24 |
25 | if defined?(JRUBY_VERSION)
26 | case config[:adapter]
27 | when /mysql/
28 | adapter_method = 'jdbc_mysql_adapter'
29 | when /postgresql/
30 | adapter_method = 'jdbc_postgresql_adapter'
31 | end
32 | end
33 |
34 | begin
35 | require("apartment/adapters/#{adapter_method}")
36 | rescue LoadError
37 | raise("The adapter `#{adapter_method}` is not yet supported")
38 | end
39 |
40 | unless respond_to?(adapter_method)
41 | raise(AdapterNotFound, "database configuration specifies nonexistent #{config[:adapter]} adapter")
42 | end
43 |
44 | send(adapter_method, config)
45 | end
46 | end
47 |
48 | # Reset config and adapter so they are regenerated
49 | #
50 | def reload!(config = nil)
51 | Thread.current[:apartment_adapter] = nil
52 | @config = config
53 | end
54 |
55 | private
56 |
57 | # Fetch the rails database configuration
58 | #
59 | def config
60 | @config ||= Apartment.connection_config
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # The test environment is used exclusively to run your application's
7 | # test suite. You never need to work with it otherwise. Remember that
8 | # your test database is "scratch space" for the test suite and is wiped
9 | # and recreated between test runs. Don't rely on the data there!
10 | config.cache_classes = true
11 |
12 | # Do not eager load code on boot. This avoids loading your whole application
13 | # just for the purpose of running a single test. If you are using a tool that
14 | # preloads Rails for running tests, you may have to set it to true.
15 | config.eager_load = false
16 |
17 | # Configure static asset server for tests with Cache-Control for performance.
18 | config.serve_static_assets = true
19 | config.static_cache_control = 'public, max-age=3600'
20 |
21 | # Show full error reports and disable caching.
22 | config.consider_all_requests_local = true
23 | config.action_controller.perform_caching = false
24 |
25 | # Raise exceptions instead of rendering exception templates.
26 | config.action_dispatch.show_exceptions = false
27 |
28 | # Disable request forgery protection in test environment.
29 | config.action_controller.allow_forgery_protection = false
30 |
31 | # Tell Action Mailer not to deliver emails to the real world.
32 | # The :test delivery method accumulates sent emails in the
33 | # ActionMailer::Base.deliveries array.
34 | config.action_mailer.delivery_method = :test
35 |
36 | # Print deprecation notices to the stderr.
37 | config.active_support.deprecation = :stderr
38 |
39 | # Raises error for missing translations
40 | # config.action_view.raise_on_missing_translations = true
41 | end
42 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.routes.draw do
4 | # The priority is based upon order of creation: first created -> highest priority.
5 | # See how all your routes lay out with "rake routes".
6 |
7 | # You can have the root of your site routed with "root"
8 | # root 'welcome#index'
9 |
10 | # Example of regular route:
11 | # get 'products/:id' => 'catalog#view'
12 |
13 | # Example of named route that can be invoked with purchase_url(id: product.id)
14 | # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase
15 |
16 | # Example resource route (maps HTTP verbs to controller actions automatically):
17 | # resources :products
18 |
19 | # Example resource route with options:
20 | # resources :products do
21 | # member do
22 | # get 'short'
23 | # post 'toggle'
24 | # end
25 | #
26 | # collection do
27 | # get 'sold'
28 | # end
29 | # end
30 |
31 | # Example resource route with sub-resources:
32 | # resources :products do
33 | # resources :comments, :sales
34 | # resource :seller
35 | # end
36 |
37 | # Example resource route with more complex sub-resources:
38 | # resources :products do
39 | # resources :comments
40 | # resources :sales do
41 | # get 'recent', on: :collection
42 | # end
43 | # end
44 |
45 | # Example resource route with concerns:
46 | # concern :toggleable do
47 | # post 'toggle'
48 | # end
49 | # resources :posts, concerns: :toggleable
50 | # resources :photos, concerns: :toggleable
51 |
52 | # Example resource route within a namespace:
53 | # namespace :admin do
54 | # # Directs /admin/products/* to Admin::ProductsController
55 | # # (app/controllers/admin/products_controller.rb)
56 | # resources :products
57 | # end
58 | end
59 |
--------------------------------------------------------------------------------
/spec/integration/connection_handling_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | describe 'connection handling monkey patch', database: :postgresql do
6 | let(:db_name) { db1 }
7 |
8 | before do
9 | Apartment.configure do |config|
10 | config.excluded_models = ['Company']
11 | config.use_schemas = true
12 | end
13 | Apartment::Tenant.init
14 | Apartment::Tenant.create(db_name)
15 | Company.create(database: db_name)
16 |
17 | Apartment.configure do |config|
18 | config.tenant_names = -> { Company.pluck(:database) }
19 | end
20 | Apartment::Tenant.reload!(config)
21 |
22 | Apartment::Tenant.switch!(db_name)
23 | User.create!(name: db_name)
24 | end
25 |
26 | after do
27 | Apartment::Tenant.drop(db_name)
28 | Apartment::Tenant.reset
29 | Company.delete_all
30 |
31 | # Apartment::Tenant.init creates per model connection.
32 | # Remove the connection after testing not to unintentionally keep the connection across tests.
33 | Apartment.excluded_models.each do |excluded_model|
34 | excluded_model.constantize.remove_connection
35 | end
36 | end
37 |
38 | context 'when ActiveRecord >= 6.0', if: ActiveRecord::VERSION::MAJOR >= 6 do
39 | let(:role) do
40 | # Choose the role depending on the ActiveRecord version.
41 | case ActiveRecord::VERSION::MAJOR
42 | when 6 then ActiveRecord::Base.writing_role # deprecated in Rails 7
43 | else ActiveRecord.writing_role
44 | end
45 | end
46 |
47 | it 'is monkey patched' do
48 | expect(ActiveRecord::ConnectionHandling.instance_methods).to(include(:connected_to_with_tenant))
49 | end
50 |
51 | it 'switches to the previous set tenant' do
52 | Apartment::Tenant.switch!(db_name)
53 | ActiveRecord::Base.connected_to(role: role) do
54 | expect(Apartment::Tenant.current).to(eq(db_name))
55 | expect(User.find_by!(name: db_name).name).to(eq(db_name))
56 | end
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/apartment/active_record/postgresql_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # rubocop:disable Style/ClassAndModuleChildren
4 |
5 | # NOTE: This patch is meant to remove any schema_prefix appart from the ones for
6 | # excluded models. The schema_prefix would be resolved by apartment's setting
7 | # of search path
8 | module Apartment::PostgreSqlAdapterPatch
9 | def default_sequence_name(table, _column)
10 | res = super
11 |
12 | # for JDBC driver, if rescued in super_method, trim leading and trailing quotes
13 | res.delete!('"') if defined?(JRUBY_VERSION)
14 |
15 | schema_prefix = "#{sequence_schema(res)}."
16 |
17 | # NOTE: Excluded models should always access the sequence from the default
18 | # tenant schema
19 | if excluded_model?(table)
20 | default_tenant_prefix = "#{Apartment::Tenant.default_tenant}."
21 |
22 | # Unless the res is already prefixed with the default_tenant_prefix
23 | # we should delete the schema_prefix and add the default_tenant_prefix
24 | unless res&.starts_with?(default_tenant_prefix)
25 | res&.delete_prefix!(schema_prefix)
26 | res = default_tenant_prefix + res
27 | end
28 |
29 | return res
30 | end
31 |
32 | # Delete the schema_prefix from the res if it is present
33 | res&.delete_prefix!(schema_prefix)
34 |
35 | res
36 | end
37 |
38 | private
39 |
40 | def sequence_schema(sequence_name)
41 | current = Apartment::Tenant.current
42 | return current unless current.is_a?(Array)
43 |
44 | current.find { |schema| sequence_name.starts_with?("#{schema}.") }
45 | end
46 |
47 | def excluded_model?(table)
48 | Apartment.excluded_models.any? { |m| m.constantize.table_name == table }
49 | end
50 | end
51 |
52 | require 'active_record/connection_adapters/postgresql_adapter'
53 |
54 | # NOTE: inject this into postgresql adapters
55 | class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
56 | include Apartment::PostgreSqlAdapterPatch
57 | end
58 | # rubocop:enable Style/ClassAndModuleChildren
59 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Dummy::Application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb
5 |
6 | # The production environment is meant for finished, "live" apps.
7 | # Code is not reloaded between requests
8 | config.cache_classes = true
9 |
10 | config.eager_load = true
11 |
12 | # Full error reports are disabled and caching is turned on
13 | config.consider_all_requests_local = false
14 | config.action_controller.perform_caching = true
15 |
16 | # Specifies the header that your server uses for sending files
17 | config.action_dispatch.x_sendfile_header = 'X-Sendfile'
18 |
19 | # For nginx:
20 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'
21 |
22 | # If you have no front-end server that supports something like X-Sendfile,
23 | # just comment this out and Rails will serve the files
24 |
25 | # See everything in the log (default is :info)
26 | # config.log_level = :debug
27 |
28 | # Use a different logger for distributed setups
29 | # config.logger = SyslogLogger.new
30 |
31 | # Use a different cache store in production
32 | # config.cache_store = :mem_cache_store
33 |
34 | # Disable Rails's static asset server
35 | # In production, Apache or nginx will already do this
36 | config.serve_static_assets = false
37 |
38 | # Enable serving of images, stylesheets, and javascripts from an asset server
39 | # config.action_controller.asset_host = "http://assets.example.com"
40 |
41 | # Disable delivery errors, bad email addresses will be ignored
42 | # config.action_mailer.raise_delivery_errors = false
43 |
44 | # Enable threaded mode
45 | # config.threadsafe!
46 |
47 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
48 | # the I18n.default_locale when a translation can not be found)
49 | config.i18n.fallbacks = true
50 |
51 | # Send deprecation notices to registered listeners
52 | config.active_support.deprecation = :notify
53 | end
54 |
--------------------------------------------------------------------------------
/lib/apartment/adapters/mysql2_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/adapters/abstract_adapter'
4 |
5 | module Apartment
6 | # Helper module to decide wether to use mysql2 adapter or mysql2 adapter with schemas
7 | module Tenant
8 | def self.mysql2_adapter(config)
9 | if Apartment.use_schemas
10 | Adapters::Mysql2SchemaAdapter.new(config)
11 | else
12 | Adapters::Mysql2Adapter.new(config)
13 | end
14 | end
15 | end
16 |
17 | module Adapters
18 | # Mysql2 Adapter
19 | class Mysql2Adapter < AbstractAdapter
20 | def initialize(config)
21 | super
22 |
23 | @default_tenant = config[:database]
24 | end
25 |
26 | protected
27 |
28 | def rescue_from
29 | Mysql2::Error
30 | end
31 | end
32 |
33 | # Mysql2 Schemas Adapter
34 | class Mysql2SchemaAdapter < AbstractAdapter
35 | def initialize(config)
36 | super
37 |
38 | @default_tenant = config[:database]
39 | reset
40 | end
41 |
42 | # Reset current tenant to the default_tenant
43 | #
44 | def reset
45 | return unless default_tenant
46 |
47 | Apartment.connection.execute("use `#{default_tenant}`")
48 | end
49 |
50 | protected
51 |
52 | # Connect to new tenant
53 | #
54 | def connect_to_new(tenant)
55 | return reset if tenant.nil?
56 |
57 | Apartment.connection.execute("use `#{environmentify(tenant)}`")
58 | rescue ActiveRecord::StatementInvalid => e
59 | Apartment::Tenant.reset
60 | raise_connect_error!(tenant, e)
61 | end
62 |
63 | def process_excluded_model(model)
64 | model.constantize.tap do |klass|
65 | # Ensure that if a schema *was* set, we override
66 | table_name = klass.table_name.split('.', 2).last
67 |
68 | klass.table_name = "#{default_tenant}.#{table_name}"
69 | end
70 | end
71 |
72 | def reset_on_connection_exception?
73 | true
74 | end
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/spec/dummy/db/schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is auto-generated from the current state of the database. Instead
4 | # of editing this file, please use the migrations feature of Active Record to
5 | # incrementally modify your database, and then regenerate this schema definition.
6 | #
7 | # Note that this schema.rb definition is the authoritative source for your
8 | # database schema. If you need to create the application database on another
9 | # system, you should be using db:schema:load, not running all the migrations
10 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
11 | # you'll amass, the slower it'll run and the greater likelihood for issues).
12 | #
13 | # It's strongly recommended that you check this file into your version control system.
14 |
15 | ActiveRecord::Schema.define(version: 20_180_415_260_934) do
16 | # These are extensions that must be enabled in order to support this database
17 | enable_extension 'plpgsql' if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL'
18 |
19 | create_table 'books', force: :cascade do |t|
20 | t.string('name')
21 | t.integer('pages')
22 | t.datetime('published')
23 | end
24 |
25 | create_table 'companies', force: :cascade do |t|
26 | t.boolean('dummy')
27 | t.string('database')
28 | end
29 |
30 | create_table 'delayed_jobs', force: :cascade do |t|
31 | t.integer('priority', default: 0)
32 | t.integer('attempts', default: 0)
33 | t.text('handler')
34 | t.text('last_error')
35 | t.datetime('run_at')
36 | t.datetime('locked_at')
37 | t.datetime('failed_at')
38 | t.string('locked_by')
39 | t.datetime('created_at')
40 | t.datetime('updated_at')
41 | t.string('queue')
42 | t.index(%w[priority run_at], name: 'delayed_jobs_priority')
43 | end
44 |
45 | create_table 'public_tokens', id: :serial, force: :cascade do |t|
46 | t.string('token')
47 | t.integer('user_id')
48 | end
49 |
50 | create_table 'users', force: :cascade do |t|
51 | t.string('name')
52 | t.datetime('birthdate')
53 | t.string('sex')
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/apartment/adapters/jdbc_postgresql_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/adapters/postgresql_adapter'
4 |
5 | module Apartment
6 | # JDBC helper to decide wether to use JDBC Postgresql Adapter or JDBC Postgresql Adapter with Schemas
7 | module Tenant
8 | def self.jdbc_postgresql_adapter(config)
9 | if Apartment.use_schemas
10 | Adapters::JDBCPostgresqlSchemaAdapter.new(config)
11 | else
12 | Adapters::JDBCPostgresqlAdapter.new(config)
13 | end
14 | end
15 | end
16 |
17 | module Adapters
18 | # Default adapter when not using Postgresql Schemas
19 | class JDBCPostgresqlAdapter < PostgresqlAdapter
20 | private
21 |
22 | def multi_tenantify_with_tenant_db_name(config, tenant)
23 | config[:url] = "#{config[:url].gsub(%r{(\S+)/.+$}, '\1')}/#{environmentify(tenant)}"
24 | end
25 |
26 | def create_tenant_command(conn, tenant)
27 | conn.create_database(environmentify(tenant), thisisahack: '')
28 | end
29 |
30 | def rescue_from
31 | ActiveRecord::JDBCError
32 | end
33 | end
34 |
35 | # Separate Adapter for Postgresql when using schemas
36 | class JDBCPostgresqlSchemaAdapter < PostgresqlSchemaAdapter
37 | # Set schema search path to new schema
38 | #
39 | def connect_to_new(tenant = nil)
40 | return reset if tenant.nil?
41 | raise(ActiveRecord::StatementInvalid, "Could not find schema #{tenant}") unless schema_exists?(tenant)
42 |
43 | @current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s
44 | Apartment.connection.schema_search_path = full_search_path
45 | rescue ActiveRecord::StatementInvalid, ActiveRecord::JDBCError
46 | raise(TenantNotFound, "One of the following schema(s) is invalid: #{full_search_path}")
47 | end
48 |
49 | private
50 |
51 | def tenant_exists?(tenant)
52 | return true unless Apartment.tenant_presence_check
53 |
54 | Apartment.connection.all_schemas.include?(tenant)
55 | end
56 |
57 | def rescue_from
58 | ActiveRecord::JDBCError
59 | end
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/apartment/migrator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/tenant'
4 |
5 | module Apartment
6 | module Migrator
7 | module_function
8 |
9 | # Migrate to latest
10 | def migrate(database)
11 | # Pin a connection for the entire migration to ensure Tenant.switch
12 | # sets search_path on the same connection used by migration_context.
13 | # Without this, connection pool may return different connections
14 | # for the switch vs the actual migration operations.
15 | ActiveRecord::Base.connection_pool.with_connection do
16 | Tenant.switch(database) do
17 | version = ENV['VERSION']&.to_i
18 |
19 | migration_scope_block = ->(migration) { ENV['SCOPE'].blank? || (ENV['SCOPE'] == migration.scope) }
20 |
21 | if ActiveRecord.version >= Gem::Version.new('7.2.0')
22 | ActiveRecord::Base.connection_pool.migration_context.migrate(version, &migration_scope_block)
23 | else
24 | ActiveRecord::Base.connection.migration_context.migrate(version, &migration_scope_block)
25 | end
26 | end
27 | end
28 | end
29 |
30 | # Migrate up/down to a specific version
31 | def run(direction, database, version)
32 | ActiveRecord::Base.connection_pool.with_connection do
33 | Tenant.switch(database) do
34 | if ActiveRecord.version >= Gem::Version.new('7.2.0')
35 | ActiveRecord::Base.connection_pool.migration_context.run(direction, version)
36 | else
37 | ActiveRecord::Base.connection.migration_context.run(direction, version)
38 | end
39 | end
40 | end
41 | end
42 |
43 | # rollback latest migration `step` number of times
44 | def rollback(database, step = 1)
45 | ActiveRecord::Base.connection_pool.with_connection do
46 | Tenant.switch(database) do
47 | if ActiveRecord.version >= Gem::Version.new('7.2.0')
48 | ActiveRecord::Base.connection_pool.migration_context.rollback(step)
49 | else
50 | ActiveRecord::Base.connection.migration_context.rollback(step)
51 | end
52 | end
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/spec/adapters/mysql2_adapter_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if !defined?(JRUBY_VERSION) && ENV['DATABASE_ENGINE'] == 'mysql'
4 |
5 | require 'spec_helper'
6 | require 'apartment/adapters/mysql2_adapter'
7 |
8 | describe Apartment::Adapters::Mysql2Adapter, database: :mysql do
9 | subject(:adapter) { Apartment::Tenant.adapter }
10 |
11 | def tenant_names
12 | ActiveRecord::Base.connection.execute('SELECT schema_name FROM information_schema.schemata').pluck(0)
13 | end
14 |
15 | let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } }
16 |
17 | it_behaves_like 'a generic apartment adapter callbacks'
18 |
19 | context 'when using - the equivalent of - schemas' do
20 | before { Apartment.use_schemas = true }
21 |
22 | it_behaves_like 'a generic apartment adapter'
23 |
24 | describe '#default_tenant' do
25 | it 'is set to the original db from config' do
26 | expect(subject.default_tenant).to(eq(config[:database]))
27 | end
28 | end
29 |
30 | describe '#init' do
31 | include Apartment::Spec::AdapterRequirements
32 |
33 | before do
34 | Apartment.configure do |config|
35 | config.excluded_models = ['Company']
36 | end
37 | end
38 |
39 | after do
40 | # Apartment::Tenant.init creates per model connection.
41 | # Remove the connection after testing not to unintentionally keep the connection across tests.
42 | Apartment.excluded_models.each do |excluded_model|
43 | excluded_model.constantize.remove_connection
44 | end
45 | end
46 |
47 | it 'processes model exclusions' do
48 | Apartment::Tenant.init
49 |
50 | expect(Company.table_name).to(eq("#{default_tenant}.companies"))
51 | end
52 | end
53 | end
54 |
55 | context 'when using connections' do
56 | before { Apartment.use_schemas = false }
57 |
58 | it_behaves_like 'a generic apartment adapter'
59 | it_behaves_like 'a generic apartment adapter able to handle custom configuration'
60 | it_behaves_like 'a connection based apartment adapter'
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/spec/adapters/trilogy_adapter_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if !defined?(JRUBY_VERSION) && ENV['DATABASE_ENGINE'] == 'mysql'
4 |
5 | require 'spec_helper'
6 | require 'apartment/adapters/trilogy_adapter'
7 |
8 | describe Apartment::Adapters::TrilogyAdapter, database: :mysql do
9 | subject(:adapter) { Apartment::Tenant.adapter }
10 |
11 | def tenant_names
12 | ActiveRecord::Base.connection.execute('SELECT schema_name FROM information_schema.schemata').pluck(0)
13 | end
14 |
15 | let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } }
16 |
17 | it_behaves_like 'a generic apartment adapter callbacks'
18 |
19 | context 'when using - the equivalent of - schemas' do
20 | before { Apartment.use_schemas = true }
21 |
22 | it_behaves_like 'a generic apartment adapter'
23 |
24 | describe '#default_tenant' do
25 | it 'is set to the original db from config' do
26 | expect(subject.default_tenant).to(eq(config[:database]))
27 | end
28 | end
29 |
30 | describe '#init' do
31 | include Apartment::Spec::AdapterRequirements
32 |
33 | before do
34 | Apartment.configure do |config|
35 | config.excluded_models = ['Company']
36 | end
37 | end
38 |
39 | after do
40 | # Apartment::Tenant.init creates per model connection.
41 | # Remove the connection after testing not to unintentionally keep the connection across tests.
42 | Apartment.excluded_models.each do |excluded_model|
43 | excluded_model.constantize.remove_connection
44 | end
45 | end
46 |
47 | it 'processes model exclusions' do
48 | Apartment::Tenant.init
49 |
50 | expect(Company.table_name).to(eq("#{default_tenant}.companies"))
51 | end
52 | end
53 | end
54 |
55 | context 'when using connections' do
56 | before { Apartment.use_schemas = false }
57 |
58 | it_behaves_like 'a generic apartment adapter'
59 | it_behaves_like 'a generic apartment adapter able to handle custom configuration'
60 | it_behaves_like 'a connection based apartment adapter'
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/spec/apartment_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | describe Apartment do
6 | before { described_class.reset }
7 |
8 | it 'is valid' do
9 | expect(described_class).to(be_a(Module))
10 | end
11 |
12 | it 'is a valid app' do
13 | expect(Rails.application).to(be_a(Dummy::Application))
14 | end
15 |
16 | describe 'configuration' do
17 | describe '.parallel_strategy' do
18 | it 'defaults to :auto' do
19 | expect(described_class.parallel_strategy).to(eq(:auto))
20 | end
21 |
22 | it 'can be set to :threads' do
23 | described_class.parallel_strategy = :threads
24 | expect(described_class.parallel_strategy).to(eq(:threads))
25 | end
26 |
27 | it 'can be set to :processes' do
28 | described_class.parallel_strategy = :processes
29 | expect(described_class.parallel_strategy).to(eq(:processes))
30 | end
31 | end
32 |
33 | describe '.manage_advisory_locks' do
34 | it 'defaults to true' do
35 | expect(described_class.manage_advisory_locks).to(be(true))
36 | end
37 |
38 | it 'can be set to false' do
39 | described_class.manage_advisory_locks = false
40 | expect(described_class.manage_advisory_locks).to(be(false))
41 | end
42 | end
43 |
44 | describe '.parallel_migration_threads' do
45 | it 'defaults to 0' do
46 | expect(described_class.parallel_migration_threads).to(eq(0))
47 | end
48 |
49 | it 'can be set to a positive number' do
50 | described_class.parallel_migration_threads = 4
51 | expect(described_class.parallel_migration_threads).to(eq(4))
52 | end
53 | end
54 |
55 | describe '.reset' do
56 | it 'resets all configuration options to defaults' do
57 | described_class.parallel_strategy = :threads
58 | described_class.manage_advisory_locks = false
59 | described_class.parallel_migration_threads = 8
60 |
61 | described_class.reset
62 |
63 | expect(described_class.parallel_strategy).to(eq(:auto))
64 | expect(described_class.manage_advisory_locks).to(be(true))
65 | expect(described_class.parallel_migration_threads).to(eq(0))
66 | end
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/apartment/elevators/subdomain.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/elevators/generic'
4 | require 'public_suffix'
5 |
6 | module Apartment
7 | module Elevators
8 | # Provides a rack based tenant switching solution based on subdomains
9 | # Assumes that tenant name should match subdomain
10 | #
11 | class Subdomain < Generic
12 | def self.excluded_subdomains
13 | @excluded_subdomains ||= []
14 | end
15 |
16 | # rubocop:disable Style/TrivialAccessors
17 | def self.excluded_subdomains=(arg)
18 | @excluded_subdomains = arg
19 | end
20 | # rubocop:enable Style/TrivialAccessors
21 |
22 | def parse_tenant_name(request)
23 | request_subdomain = subdomain(request.host)
24 |
25 | # Excluded subdomains (www, api, admin) return nil → uses default tenant.
26 | # Returning nil instead of default_tenant name allows Apartment to decide
27 | # the fallback behavior.
28 | tenant = if self.class.excluded_subdomains.include?(request_subdomain)
29 | nil
30 | else
31 | request_subdomain
32 | end
33 |
34 | tenant.presence
35 | end
36 |
37 | protected
38 |
39 | # Subdomain extraction using PublicSuffix to handle international TLDs correctly.
40 | # Examples: api.example.com → "api", www.example.co.uk → "www"
41 |
42 | def subdomain(host)
43 | subdomains(host).first # Only first subdomain matters for tenant resolution
44 | end
45 |
46 | def subdomains(host)
47 | host_valid?(host) ? parse_host(host) : []
48 | end
49 |
50 | def host_valid?(host)
51 | !ip_host?(host) && domain_valid?(host)
52 | end
53 |
54 | # Reject IP addresses (127.0.0.1, 192.168.1.1) - no subdomain concept
55 | def ip_host?(host)
56 | !/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil?
57 | end
58 |
59 | def domain_valid?(host)
60 | PublicSuffix.valid?(host, ignore_private: true)
61 | end
62 |
63 | # PublicSuffix.parse handles TLDs correctly: example.co.uk has TLD "co.uk"
64 | # .trd (third-level domain) returns subdomain parts, excluding TLD
65 | def parse_host(host)
66 | (PublicSuffix.parse(host, ignore_private: true).trd || '').split('.')
67 | end
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/spec/unit/migrator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'apartment/migrator'
5 |
6 | describe Apartment::Migrator do
7 | let(:tenant) { Apartment::Test.next_db }
8 | let(:connection_pool) { ActiveRecord::Base.connection_pool }
9 |
10 | # Don't need a real switch here, just testing behaviour
11 | before { allow(Apartment::Tenant.adapter).to(receive(:connect_to_new)) }
12 |
13 | context 'with ActiveRecord above or equal to 6.1.0' do
14 | describe '::migrate' do
15 | it 'switches and migrates' do
16 | expect(Apartment::Tenant).to(receive(:switch).with(tenant).and_call_original)
17 | expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:migrate))
18 |
19 | described_class.migrate(tenant)
20 | end
21 |
22 | it 'pins connection for entire migration to ensure search_path consistency' do
23 | expect(connection_pool).to(receive(:with_connection).and_call_original)
24 | expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:migrate))
25 |
26 | described_class.migrate(tenant)
27 | end
28 | end
29 |
30 | describe '::run' do
31 | it 'switches and runs' do
32 | expect(Apartment::Tenant).to(receive(:switch).with(tenant).and_call_original)
33 | expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:run).with(:up, 1234))
34 |
35 | described_class.run(:up, tenant, 1234)
36 | end
37 |
38 | it 'pins connection for entire operation to ensure search_path consistency' do
39 | expect(connection_pool).to(receive(:with_connection).and_call_original)
40 | expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:run).with(:up, 1234))
41 |
42 | described_class.run(:up, tenant, 1234)
43 | end
44 | end
45 |
46 | describe '::rollback' do
47 | it 'switches and rolls back' do
48 | expect(Apartment::Tenant).to(receive(:switch).with(tenant).and_call_original)
49 | expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:rollback).with(2))
50 |
51 | described_class.rollback(tenant, 2)
52 | end
53 |
54 | it 'pins connection for entire rollback to ensure search_path consistency' do
55 | expect(connection_pool).to(receive(:with_connection).and_call_original)
56 | expect_any_instance_of(ActiveRecord::MigrationContext).to(receive(:rollback).with(2))
57 |
58 | described_class.rollback(tenant, 2)
59 | end
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/apartment/railtie.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rails'
4 | require 'apartment/tenant'
5 |
6 | module Apartment
7 | class Railtie < Rails::Railtie
8 | #
9 | # Set up our default config options
10 | # Do this before the app initializers run so we don't override custom settings
11 | #
12 | config.before_initialize do
13 | Apartment.configure do |config|
14 | config.excluded_models = []
15 | config.use_schemas = true
16 | config.tenant_names = []
17 | config.seed_after_create = false
18 | config.prepend_environment = false
19 | config.append_environment = false
20 | config.tenant_presence_check = true
21 | config.active_record_log = false
22 | end
23 |
24 | ActiveRecord::Migrator.migrations_paths = Rails.application.paths['db/migrate'].to_a
25 | end
26 |
27 | # Hook into ActionDispatch::Reloader to ensure Apartment is properly initialized
28 | # Note that this doesn't entirely work as expected in Development,
29 | # because this is called before classes are reloaded
30 | # See the middleware/console declarations below to help with this. Hope to fix that soon.
31 | #
32 | config.to_prepare do
33 | next if ARGV.any? { |arg| arg =~ /\Aassets:(?:precompile|clean)\z/ }
34 | next if ARGV.any?('webpacker:compile')
35 | next if ENV['APARTMENT_DISABLE_INIT']
36 |
37 | begin
38 | Apartment.connection_class.connection_pool.with_connection do
39 | Apartment::Tenant.init
40 | end
41 | rescue ::ActiveRecord::NoDatabaseError
42 | # Since `db:create` and other tasks invoke this block from Rails 5.2.0,
43 | # we need to swallow the error to execute `db:create` properly.
44 | end
45 | end
46 |
47 | config.after_initialize do
48 | # NOTE: Load the custom log subscriber if enabled
49 | if Apartment.active_record_log
50 | ActiveSupport::Notifications.notifier.listeners_for('sql.active_record').each do |listener|
51 | next unless listener.instance_variable_get(:@delegate).is_a?(ActiveRecord::LogSubscriber)
52 |
53 | ActiveSupport::Notifications.unsubscribe(listener)
54 | end
55 |
56 | Apartment::LogSubscriber.attach_to(:active_record)
57 | end
58 | end
59 |
60 | #
61 | # Ensure rake tasks are loaded
62 | #
63 | rake_tasks do
64 | load 'tasks/apartment.rake'
65 | require 'apartment/tasks/enhancements' if Apartment.db_migrate_tenants
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/spec/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require File.expand_path('boot', __dir__)
4 |
5 | require 'active_model/railtie'
6 | require 'active_record/railtie'
7 | require 'action_controller/railtie'
8 | require 'action_view/railtie'
9 | require 'action_mailer/railtie'
10 |
11 | Bundler.require
12 | require 'apartment'
13 |
14 | module Dummy
15 | class Application < Rails::Application
16 | # Settings in config/environments/* take precedence over those specified here.
17 | # Application configuration should go into files in config/initializers
18 | # -- all .rb files in that directory are automatically loaded.
19 | require 'apartment/elevators/subdomain'
20 | require 'apartment/elevators/domain'
21 |
22 | config.middleware.use(Apartment::Elevators::Subdomain)
23 |
24 | # Custom directories with classes and modules you want to be autoloadable.
25 | config.autoload_paths += %W[#{config.root}/lib]
26 |
27 | # Only load the plugins named here, in the order given (default is alphabetical).
28 | # :all can be used as a placeholder for all plugins not explicitly named.
29 | # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
30 |
31 | # Activate observers that should always be running.
32 | # config.active_record.observers = :cacher, :garbage_collector, :forum_observer
33 |
34 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
35 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
36 | # config.time_zone = 'Central Time (US & Canada)'
37 |
38 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
39 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
40 | # config.i18n.default_locale = :de
41 |
42 | # JavaScript files you want as :defaults (application.js is always included).
43 | # config.action_view.javascript_expansions[:defaults] = %w(jquery rails)
44 |
45 | # Configure the default encoding used in templates for Ruby 1.9.
46 | config.encoding = 'utf-8'
47 |
48 | # Configure sensitive parameters which will be filtered from the log file.
49 | config.filter_parameters += [:password]
50 |
51 | # Use new connection handling for Rails 7.0 only (setting deprecated in 7.0, removed in 7.1)
52 | # This silences the deprecation warning about legacy_connection_handling
53 | if ActiveRecord.version >= Gem::Version.new('7.0.0') && ActiveRecord.version < Gem::Version.new('7.1.0')
54 | config.active_record.legacy_connection_handling = false
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if ENV['CI'].eql?('true') # ENV['CI'] defined as true by GitHub Actions
4 | require 'simplecov'
5 | require 'simplecov_json_formatter'
6 |
7 | SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter
8 |
9 | SimpleCov.start do
10 | track_files('lib/**/*.rb')
11 | add_filter(%r{spec(/|\.)})
12 | end
13 | end
14 |
15 | $LOAD_PATH.unshift(File.dirname(__FILE__))
16 |
17 | # Configure Rails Environment
18 | ENV['RAILS_ENV'] = 'test'
19 |
20 | require File.expand_path('dummy/config/environment.rb', __dir__)
21 |
22 | # Loading dummy applications affects table_name of each excluded models
23 | # defined in `spec/dummy/config/initializers/apartment.rb`.
24 | # To make them pristine, we need to execute below lines.
25 | Apartment.excluded_models.each do |model|
26 | klass = model.constantize
27 |
28 | klass.remove_connection
29 | klass.connection_handler.clear_all_connections!
30 | klass.reset_table_name
31 | end
32 |
33 | require 'rspec/rails'
34 |
35 | ActionMailer::Base.delivery_method = :test
36 | ActionMailer::Base.perform_deliveries = true
37 | ActionMailer::Base.default_url_options[:host] = 'test.com'
38 |
39 | Rails.backtrace_cleaner.remove_silencers!
40 |
41 | # Load support files
42 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
43 |
44 | RSpec.configure do |config|
45 | config.include(Apartment::Spec::Setup)
46 |
47 | # Somewhat brutal hack so that rails 4 postgres extensions don't modify this file
48 | config.after(:all) do
49 | `git checkout -- spec/dummy/db/schema.rb`
50 | end
51 | # rubocop:enable RSpec/BeforeAfterAll
52 |
53 | # rspec-rails 3 will no longer automatically infer an example group's spec type
54 | # from the file location. You can explicitly opt-in to the feature using this
55 | # config option.
56 | # To explicitly tag specs without using automatic inference, set the `:type`
57 | # metadata manually:
58 | #
59 | # describe ThingsController, :type => :controller do
60 | # # Equivalent to being in spec/controllers
61 | # end
62 | config.infer_spec_type_from_file_location!
63 |
64 | config.filter_run_excluding(database: lambda { |engine|
65 | case ENV.fetch('DATABASE_ENGINE', nil)
66 | when 'mysql'
67 | %i[sqlite postgresql].include?(engine)
68 | when 'sqlite'
69 | %i[mysql postgresql].include?(engine)
70 | when 'postgresql'
71 | %i[mysql sqlite].include?(engine)
72 | else
73 | false
74 | end
75 | })
76 | end
77 |
78 | # Load shared examples, must happen after configure for RSpec 3
79 | Dir["#{File.dirname(__FILE__)}/examples/**/*.rb"].each { |f| require f }
80 |
--------------------------------------------------------------------------------
/.github/workflows/rspec_sqlite_3.yml:
--------------------------------------------------------------------------------
1 | name: RSpec SQLite 3
2 | on:
3 | push:
4 | branches:
5 | - development
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 | release:
10 | types: [published]
11 |
12 | jobs:
13 | test:
14 | name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby_version:
20 | - 3.1
21 | - 3.2
22 | - 3.3
23 | - 3.4
24 | # - jruby # We don't support jruby for sqlite yet
25 | rails_version:
26 | - 7_0
27 | - 7_1
28 | - 7_2
29 | - 8_0
30 | - 8_1
31 | exclude:
32 | - ruby_version: 3.1
33 | rails_version: 8_0
34 | - ruby_version: 3.1
35 | rails_version: 8_1
36 | env:
37 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_sqlite3.gemfile
38 | CI: true
39 | DATABASE_ENGINE: sqlite
40 | RUBY_VERSION: ${{ matrix.ruby_version }}
41 | RAILS_VERSION: ${{ matrix.rails_version }}
42 | steps:
43 | - uses: actions/checkout@v4
44 | - name: Set up Ruby ${{ matrix.ruby-version }}
45 | uses: ruby/setup-ruby@v1
46 | with:
47 | ruby-version: ${{ matrix.ruby_version }}
48 | bundler-cache: true
49 | - name: Configure config database.yml
50 | run: bundle exec rake db:load_credentials
51 | - name: Database Setup
52 | run: bundle exec rake db:test:prepare
53 | - name: Run tests
54 | id: rspec-tests
55 | timeout-minutes: 20
56 | continue-on-error: true
57 | run: |
58 | mkdir -p ./coverage
59 | bundle exec rspec --format progress \
60 | --format RspecJunitFormatter -o ./coverage/test-results.xml \
61 | --profile
62 | - name: Codecov Upload
63 | uses: codecov/codecov-action@v4
64 | with:
65 | token: ${{ secrets.CODECOV_TOKEN }}
66 | verbose: true
67 | disable_search: true
68 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
69 | file: ./coverage/coverage.json
70 | - name: Upload test results to Codecov
71 | uses: codecov/test-results-action@v1
72 | with:
73 | token: ${{ secrets.CODECOV_TOKEN }}
74 | verbose: true
75 | disable_search: true
76 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
77 | file: ./coverage/test-results.xml
78 | - name: Notify of test failure
79 | if: steps.rspec-tests.outcome == 'failure'
80 | run: exit 1
81 |
--------------------------------------------------------------------------------
/spec/unit/elevators/subdomain_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'apartment/elevators/subdomain'
5 |
6 | describe Apartment::Elevators::Subdomain do
7 | subject(:elevator) { described_class.new(proc {}) }
8 |
9 | describe '#parse_tenant_name' do
10 | context 'when assuming one tld' do
11 | it 'parses subdomain' do
12 | request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com')
13 | expect(elevator.parse_tenant_name(request)).to(eq('foo'))
14 | end
15 |
16 | it 'returns nil when no subdomain' do
17 | request = ActionDispatch::Request.new('HTTP_HOST' => 'bar.com')
18 | expect(elevator.parse_tenant_name(request)).to(be_nil)
19 | end
20 | end
21 |
22 | context 'when assuming two tlds' do
23 | it 'parses subdomain in the third level domain' do
24 | request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.co.uk')
25 | expect(elevator.parse_tenant_name(request)).to(eq('foo'))
26 | end
27 |
28 | it 'returns nil when no subdomain in the third level domain' do
29 | request = ActionDispatch::Request.new('HTTP_HOST' => 'bar.co.uk')
30 | expect(elevator.parse_tenant_name(request)).to(be_nil)
31 | end
32 | end
33 |
34 | context 'when assuming two subdomains' do
35 | it 'parses two subdomains in the two level domain' do
36 | request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.xyz.bar.com')
37 | expect(elevator.parse_tenant_name(request)).to(eq('foo'))
38 | end
39 |
40 | it 'parses two subdomains in the third level domain' do
41 | request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.xyz.bar.co.uk')
42 | expect(elevator.parse_tenant_name(request)).to(eq('foo'))
43 | end
44 | end
45 |
46 | context 'when assuming localhost' do
47 | it 'returns nil for localhost' do
48 | request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost')
49 | expect(elevator.parse_tenant_name(request)).to(be_nil)
50 | end
51 | end
52 |
53 | context 'when assuming ip address' do
54 | it 'returns nil for an ip address' do
55 | request = ActionDispatch::Request.new('HTTP_HOST' => '127.0.0.1')
56 | expect(elevator.parse_tenant_name(request)).to(be_nil)
57 | end
58 | end
59 | end
60 |
61 | describe '#call' do
62 | it 'switches to the proper tenant' do
63 | expect(Apartment::Tenant).to(receive(:switch).with('tenant1'))
64 | elevator.call('HTTP_HOST' => 'tenant1.example.com')
65 | end
66 |
67 | it 'ignores excluded subdomains' do
68 | described_class.excluded_subdomains = %w[foo]
69 |
70 | expect(Apartment::Tenant).not_to(receive(:switch))
71 |
72 | elevator.call('HTTP_HOST' => 'foo.bar.com')
73 |
74 | described_class.excluded_subdomains = nil
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/spec/integration/query_caching_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | describe 'query caching' do
6 | describe 'when use_schemas = true', database: :postgresql do
7 | let(:db_names) { [db1, db2] }
8 |
9 | before do
10 | Apartment.configure do |config|
11 | config.excluded_models = ['Company']
12 | config.tenant_names = -> { Company.pluck(:database) }
13 | config.use_schemas = true
14 | end
15 |
16 | Apartment::Tenant.reload!(config)
17 | Apartment::Tenant.init
18 |
19 | db_names.each do |db_name|
20 | Apartment::Tenant.create(db_name)
21 | Company.create(database: db_name)
22 | end
23 | end
24 |
25 | after do
26 | db_names.each { |db| Apartment::Tenant.drop(db) }
27 | Apartment::Tenant.reset
28 | Company.delete_all
29 |
30 | # Apartment::Tenant.init creates per model connection.
31 | # Remove the connection after testing not to unintentionally keep the connection across tests.
32 | Apartment.excluded_models.each do |excluded_model|
33 | excluded_model.constantize.remove_connection
34 | end
35 | end
36 |
37 | it 'clears the ActiveRecord::QueryCache after switching databases' do
38 | db_names.each do |db_name|
39 | Apartment::Tenant.switch!(db_name)
40 | User.create!(name: db_name)
41 | end
42 |
43 | ActiveRecord::Base.connection.enable_query_cache!
44 |
45 | Apartment::Tenant.switch!(db_names.first)
46 | expect(User.find_by(name: db_names.first).name).to(eq(db_names.first))
47 |
48 | Apartment::Tenant.switch!(db_names.last)
49 | expect(User.find_by(name: db_names.first)).to(be_nil)
50 | end
51 | end
52 |
53 | describe 'when use_schemas = false', database: :mysql do
54 | let(:db_name) { db1 }
55 |
56 | before do
57 | Apartment.configure do |config|
58 | config.excluded_models = ['Company']
59 | config.tenant_names = -> { Company.pluck(:database) }
60 | config.use_schemas = false
61 | end
62 |
63 | Apartment::Tenant.reload!(config)
64 | Apartment::Tenant.init
65 |
66 | Apartment::Tenant.create(db_name)
67 | Company.create(database: db_name)
68 | end
69 |
70 | after do
71 | Apartment::Tenant.reset
72 |
73 | Apartment::Tenant.drop(db_name)
74 | Company.delete_all
75 |
76 | # Apartment::Tenant.init creates per model connection.
77 | # Remove the connection after testing not to unintentionally keep the connection across tests.
78 | Apartment.excluded_models.each do |excluded_model|
79 | excluded_model.constantize.remove_connection
80 | end
81 | end
82 |
83 | it 'configuration value is kept after switching databases' do
84 | ActiveRecord::Base.connection.enable_query_cache!
85 |
86 | Apartment::Tenant.switch!(db_name)
87 | expect(Apartment.connection.query_cache_enabled).to(be(true))
88 |
89 | ActiveRecord::Base.connection.disable_query_cache!
90 |
91 | Apartment::Tenant.switch!(db_name)
92 | expect(Apartment.connection.query_cache_enabled).to(be(false))
93 | end
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/spec/adapters/sqlite3_adapter_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if !defined?(JRUBY_VERSION) && (ENV['DATABASE_ENGINE'] == 'sqlite' || ENV['DATABASE_ENGINE'].nil?)
4 |
5 | require 'spec_helper'
6 | require 'apartment/adapters/sqlite3_adapter'
7 |
8 | describe Apartment::Adapters::Sqlite3Adapter, database: :sqlite do
9 | subject(:adapter) { Apartment::Tenant.adapter }
10 |
11 | it_behaves_like 'a generic apartment adapter callbacks'
12 |
13 | context 'using connections' do
14 | def tenant_names
15 | db_dir = File.expand_path('../dummy/db', __dir__)
16 | Dir.glob("#{db_dir}/*.sqlite3").map { |file| File.basename(file, '.sqlite3') }
17 | end
18 |
19 | let(:default_tenant) do
20 | subject.switch { File.basename(Apartment::Test.config['connections']['sqlite']['database'], '.sqlite3') }
21 | end
22 |
23 | after(:all) { FileUtils.rm_f(Apartment::Test.config['connections']['sqlite']['database']) }
24 |
25 | it_behaves_like 'a generic apartment adapter'
26 | it_behaves_like 'a connection based apartment adapter'
27 | end
28 |
29 | context 'with prepend and append' do
30 | let(:default_dir) { File.expand_path(File.dirname(config[:database])) }
31 |
32 | describe '#prepend' do
33 | let(:db_name) { 'db_with_prefix' }
34 |
35 | before do
36 | Apartment.configure do |config|
37 | config.prepend_environment = true
38 | config.append_environment = false
39 | end
40 | end
41 |
42 | after do
43 | subject.drop(db_name)
44 | rescue StandardError => _e
45 | nil
46 | end
47 |
48 | it 'creates a new database' do
49 | subject.create(db_name)
50 |
51 | expect(File.exist?("#{default_dir}/#{Rails.env}_#{db_name}.sqlite3")).to(be(true))
52 | end
53 | end
54 |
55 | describe '#neither' do
56 | let(:db_name) { 'db_without_prefix_suffix' }
57 |
58 | before do
59 | Apartment.configure { |config| config.prepend_environment = config.append_environment = false }
60 | end
61 |
62 | after do
63 | subject.drop(db_name)
64 | rescue StandardError => _e
65 | nil
66 | end
67 |
68 | it 'creates a new database' do
69 | subject.create(db_name)
70 |
71 | expect(File.exist?("#{default_dir}/#{db_name}.sqlite3")).to(be(true))
72 | end
73 | end
74 |
75 | describe '#append' do
76 | let(:db_name) { 'db_with_suffix' }
77 |
78 | before do
79 | Apartment.configure do |config|
80 | config.prepend_environment = false
81 | config.append_environment = true
82 | end
83 | end
84 |
85 | after do
86 | subject.drop(db_name)
87 | rescue StandardError => _e
88 | nil
89 | end
90 |
91 | it 'creates a new database' do
92 | subject.create(db_name)
93 |
94 | expect(File.exist?("#{default_dir}/#{db_name}_#{Rails.env}.sqlite3")).to(be(true))
95 | end
96 | end
97 | end
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/spec/integration/apartment_rake_integration_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'rake'
5 |
6 | describe 'apartment rake tasks', database: :postgresql do
7 | before do
8 | @rake = Rake::Application.new
9 | Rake.application = @rake
10 | Dummy::Application.load_tasks
11 |
12 | # rails tasks running F up the schema...
13 | Rake::Task.define_task('db:migrate')
14 | Rake::Task.define_task('db:seed')
15 | Rake::Task.define_task('db:rollback')
16 | Rake::Task.define_task('db:migrate:up')
17 | Rake::Task.define_task('db:migrate:down')
18 | Rake::Task.define_task('db:migrate:redo')
19 |
20 | Apartment.configure do |config|
21 | config.use_schemas = true
22 | config.excluded_models = ['Company']
23 | config.tenant_names = -> { Company.pluck(:database) }
24 | end
25 | Apartment::Tenant.reload!(config)
26 | Apartment::Tenant.init
27 | end
28 |
29 | after do
30 | Rake.application = nil
31 |
32 | # Apartment::Tenant.init creates per model connection.
33 | # Remove the connection after testing not to unintentionally keep the connection across tests.
34 | Apartment.excluded_models.each do |excluded_model|
35 | excluded_model.constantize.remove_connection
36 | end
37 | end
38 |
39 | context 'with x number of databases' do
40 | let(:x) { rand(1..5) } # random number of dbs to create
41 | let(:db_names) { Array.new(x).map { Apartment::Test.next_db } }
42 | let!(:company_count) { db_names.length }
43 |
44 | before do
45 | db_names.collect do |db_name|
46 | Apartment::Tenant.create(db_name)
47 | Company.create(database: db_name)
48 | end
49 | end
50 |
51 | after do
52 | db_names.each { |db| Apartment::Tenant.drop(db) }
53 | Company.delete_all
54 | end
55 |
56 | context 'with ActiveRecord above or equal to 5.2.0' do
57 | let(:migration_context_double) { double(:migration_context) }
58 |
59 | describe '#migrate' do
60 | it 'migrates all databases' do
61 | if ActiveRecord.version >= Gem::Version.new('7.2.0')
62 | allow(ActiveRecord::Base.connection_pool)
63 | else
64 | allow(ActiveRecord::Base.connection)
65 | end.to(receive(:migration_context) { migration_context_double })
66 | expect(migration_context_double).to(receive(:migrate).exactly(company_count).times)
67 |
68 | @rake['apartment:migrate'].invoke
69 | end
70 | end
71 |
72 | describe '#rollback' do
73 | it 'rollbacks all dbs' do
74 | if ActiveRecord.version >= Gem::Version.new('7.2.0')
75 | allow(ActiveRecord::Base.connection_pool)
76 | else
77 | allow(ActiveRecord::Base.connection)
78 | end.to(receive(:migration_context) { migration_context_double })
79 | expect(migration_context_double).to(receive(:rollback).exactly(company_count).times)
80 |
81 | @rake['apartment:rollback'].invoke
82 | end
83 | end
84 | end
85 |
86 | describe 'apartment:seed' do
87 | it 'seeds all databases' do
88 | expect(Apartment::Tenant).to(receive(:seed).exactly(company_count).times)
89 |
90 | @rake['apartment:seed'].invoke
91 | end
92 | end
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/spec/examples/generic_adapter_custom_configuration_example.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | shared_examples_for 'a generic apartment adapter able to handle custom configuration' do
6 | let(:custom_tenant_name) { 'test_tenantwwww' }
7 | let(:db) { |example| example.metadata[:database] }
8 | let(:custom_tenant_names) do
9 | {
10 | custom_tenant_name => custom_db_conf,
11 | }
12 | end
13 |
14 | before do
15 | Apartment.tenant_names = custom_tenant_names
16 | Apartment.with_multi_server_setup = true
17 | end
18 |
19 | after do
20 | Apartment.with_multi_server_setup = false
21 | end
22 |
23 | context 'database key taken from specific config' do
24 | let(:expected_args) { custom_db_conf }
25 |
26 | describe '#create' do
27 | it 'establish_connections with the separate connection with expected args' do
28 | expect(Apartment::Adapters::AbstractAdapter::SeparateDbConnectionHandler).to(
29 | receive(:establish_connection).with(expected_args).and_call_original
30 | )
31 |
32 | # because we don't have another server to connect to it errors
33 | # what matters is establish_connection receives proper args
34 | expect { subject.create(custom_tenant_name) }.to(raise_error(Apartment::TenantExists))
35 | end
36 | end
37 |
38 | describe '#drop' do
39 | it 'establish_connections with the separate connection with expected args' do
40 | expect(Apartment::Adapters::AbstractAdapter::SeparateDbConnectionHandler).to(
41 | receive(:establish_connection).with(expected_args).and_call_original
42 | )
43 |
44 | # because we dont have another server to connect to it errors
45 | # what matters is establish_connection receives proper args
46 | expect { subject.drop(custom_tenant_name) }.to(raise_error(Apartment::TenantNotFound))
47 | end
48 | end
49 | end
50 |
51 | context 'database key from tenant name' do
52 | let(:expected_args) do
53 | custom_db_conf.tap { |args| args.delete(:database) }
54 | end
55 |
56 | describe '#switch!' do
57 | it 'connects to new db' do
58 | expect(Apartment).to(receive(:establish_connection)) do |args|
59 | db_name = args.delete(:database)
60 |
61 | expect(args).to(eq(expected_args))
62 | expect(db_name).to(match(custom_tenant_name))
63 |
64 | # we only need to check args, then we short circuit
65 | # in order to avoid the mess due to the `establish_connection` override
66 | raise ActiveRecord::ActiveRecordError
67 | end
68 |
69 | expect { subject.switch!(custom_tenant_name) }.to(raise_error(Apartment::TenantNotFound))
70 | end
71 | end
72 | end
73 |
74 | def specific_connection
75 | {
76 | postgresql: {
77 | adapter: 'postgresql',
78 | database: 'override_database',
79 | password: 'override_password',
80 | username: 'overridepostgres',
81 | },
82 | mysql: {
83 | adapter: 'mysql2',
84 | database: 'override_database',
85 | username: 'root',
86 | },
87 | sqlite: {
88 | adapter: 'sqlite3',
89 | database: 'override_database',
90 | },
91 | }
92 | end
93 |
94 | def custom_db_conf
95 | specific_connection[db.to_sym].with_indifferent_access
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/RELEASING.md:
--------------------------------------------------------------------------------
1 | # Releasing ros-apartment
2 |
3 | This document describes the release process for the `ros-apartment` gem.
4 |
5 | ## Overview
6 |
7 | Releases are automated via GitHub Actions. Pushing to `main` triggers the `gem-publish.yml` workflow, which publishes to RubyGems using trusted publishing (no API key required).
8 |
9 | ## Prerequisites
10 |
11 | - All changes merged to `development` branch
12 | - CI passing on `development`
13 | - Version number updated in `lib/apartment/version.rb`
14 |
15 | ## Release Steps
16 |
17 | ### 1. Bump the version
18 |
19 | Update `lib/apartment/version.rb` on the `development` branch:
20 |
21 | ```ruby
22 | module Apartment
23 | VERSION = 'X.Y.Z'
24 | end
25 | ```
26 |
27 | Follow [Semantic Versioning](https://semver.org/):
28 | - **MAJOR** (X): Breaking changes
29 | - **MINOR** (Y): New features, backwards compatible
30 | - **PATCH** (Z): Bug fixes, backwards compatible
31 |
32 | ### 2. Create release PR
33 |
34 | Create a PR from `development` to `main`:
35 |
36 | ```bash
37 | gh pr create --base main --head development --title "Release vX.Y.Z"
38 | ```
39 |
40 | Include a summary of changes in the PR description.
41 |
42 | ### 3. Merge the release PR
43 |
44 | Once CI passes and the PR is approved, merge it. This triggers the publish workflow.
45 |
46 | **Important**: The workflow creates the git tag automatically. Do not create the tag manually beforehand or the workflow will fail.
47 |
48 | ### 4. Verify the publish
49 |
50 | Monitor the `gem-publish.yml` workflow run. It will:
51 | 1. Build the gem
52 | 2. Create and push the `vX.Y.Z` tag
53 | 3. Publish to RubyGems
54 | 4. Wait for RubyGems indexes to update
55 |
56 | Verify at: https://rubygems.org/gems/ros-apartment
57 |
58 | ### 5. Create GitHub Release
59 |
60 | After the workflow completes:
61 |
62 | 1. Go to https://github.com/rails-on-services/apartment/releases/new
63 | 2. Select the `vX.Y.Z` tag (created by the workflow)
64 | 3. Click "Generate release notes" for a starting point
65 | 4. Edit the release notes to highlight key changes
66 | 5. Publish the release
67 |
68 | We use GitHub Releases as our changelog (no CHANGELOG.md file).
69 |
70 | ### 6. Sync branches
71 |
72 | Merge `main` back into `development` to keep them in sync:
73 |
74 | ```bash
75 | git checkout development
76 | git pull origin development
77 | git merge origin/main --no-edit
78 | git push
79 | ```
80 |
81 | ## Workflow Details
82 |
83 | The `gem-publish.yml` workflow uses:
84 | - **Trusted publishing**: Configured via RubyGems.org OIDC, no API key needed
85 | - **rubygems/release-gem@v1**: Official RubyGems action
86 | - **rake release**: Builds gem, creates tag, pushes to RubyGems
87 |
88 | ## Troubleshooting
89 |
90 | ### Workflow fails with "tag already exists"
91 |
92 | The tag was created manually before the workflow ran. Delete the tag and re-run:
93 |
94 | ```bash
95 | git push origin --delete vX.Y.Z
96 | ```
97 |
98 | Then re-trigger the workflow by pushing to main again (or re-run from GitHub Actions UI).
99 |
100 | ### Gem published but GitHub Release missing
101 |
102 | The GitHub Release is created manually (step 5). The gem is already available on RubyGems; the release is just for documentation.
103 |
104 | ### RubyGems trusted publishing fails
105 |
106 | Verify the GitHub environment `production` is configured correctly in repository settings, and that RubyGems.org has the trusted publisher configured for this repository.
107 |
--------------------------------------------------------------------------------
/spec/dummy_engine/test/dummy/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # Code is not reloaded between requests.
7 | config.cache_classes = true
8 |
9 | # Eager load code on boot. This eager loads most of Rails and
10 | # your application in memory, allowing both threaded web servers
11 | # and those relying on copy on write to perform better.
12 | # Rake tasks automatically ignore this option for performance.
13 | config.eager_load = true
14 |
15 | # Full error reports are disabled and caching is turned on.
16 | config.consider_all_requests_local = false
17 | config.action_controller.perform_caching = true
18 |
19 | # Enable Rack::Cache to put a simple HTTP cache in front of your application
20 | # Add `rack-cache` to your Gemfile before enabling this.
21 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid.
22 | # config.action_dispatch.rack_cache = true
23 |
24 | # Disable Rails's static asset server (Apache or nginx will already do this).
25 | config.serve_static_assets = false
26 |
27 | # Compress JavaScripts and CSS.
28 | config.assets.js_compressor = :uglifier
29 | # config.assets.css_compressor = :sass
30 |
31 | # Do not fallback to assets pipeline if a precompiled asset is missed.
32 | config.assets.compile = false
33 |
34 | # Generate digests for assets URLs.
35 | config.assets.digest = true
36 |
37 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
38 |
39 | # Specifies the header that your server uses for sending files.
40 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache
41 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx
42 |
43 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
44 | # config.force_ssl = true
45 |
46 | # Set to :debug to see everything in the log.
47 | config.log_level = :info
48 |
49 | # Prepend all log lines with the following tags.
50 | # config.log_tags = [ :subdomain, :uuid ]
51 |
52 | # Use a different logger for distributed setups.
53 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
54 |
55 | # Use a different cache store in production.
56 | # config.cache_store = :mem_cache_store
57 |
58 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
59 | # config.action_controller.asset_host = "http://assets.example.com"
60 |
61 | # Ignore bad email addresses and do not raise email delivery errors.
62 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
63 | # config.action_mailer.raise_delivery_errors = false
64 |
65 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
66 | # the I18n.default_locale when a translation cannot be found).
67 | config.i18n.fallbacks = true
68 |
69 | # Send deprecation notices to registered listeners.
70 | config.active_support.deprecation = :notify
71 |
72 | # Disable automatic flushing of the log to improve performance.
73 | # config.autoflush_log = false
74 |
75 | # Use default logging formatter so that PID and timestamp are not suppressed.
76 | config.log_formatter = Logger::Formatter.new
77 |
78 | # Do not dump schema after migrations.
79 | config.active_record.dump_schema_after_migration = false
80 | end
81 |
--------------------------------------------------------------------------------
/.github/workflows/rspec_mysql_8_0.yml:
--------------------------------------------------------------------------------
1 | name: RSpec MySQL 8.0
2 | on:
3 | push:
4 | branches:
5 | - development
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 | release:
10 | types: [published]
11 |
12 | jobs:
13 | test:
14 | name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby_version:
20 | - 3.1
21 | - 3.2
22 | - 3.3
23 | - 3.4
24 | - jruby
25 | rails_version:
26 | - 7_0
27 | - 7_1
28 | - 7_2
29 | - 8_0
30 | - 8_1
31 | exclude:
32 | - ruby_version: jruby
33 | rails_version: 7_1
34 | - ruby_version: jruby
35 | rails_version: 7_2
36 | - ruby_version: jruby
37 | rails_version: 8_0
38 | - ruby_version: 3.1
39 | rails_version: 8_0
40 | - ruby_version: jruby
41 | rails_version: 8_1
42 | - ruby_version: 3.1
43 | rails_version: 8_1
44 | env:
45 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_mysql.gemfile
46 | CI: true
47 | DATABASE_ENGINE: mysql
48 | RUBY_VERSION: ${{ matrix.ruby_version }}
49 | RAILS_VERSION: ${{ matrix.rails_version }}
50 | services:
51 | mysql:
52 | image: mysql:8.0
53 | env:
54 | MYSQL_ALLOW_EMPTY_PASSWORD: true
55 | MYSQL_DATABASE: apartment_mysql_test
56 | options: >-
57 | --health-cmd "mysqladmin ping"
58 | --health-interval 10s
59 | --health-timeout 5s
60 | --health-retries 5
61 | ports:
62 | - 3306:3306
63 | steps:
64 | - uses: actions/checkout@v4
65 | - name: Set up Ruby ${{ matrix.ruby-version }}
66 | uses: ruby/setup-ruby@v1
67 | with:
68 | ruby-version: ${{ matrix.ruby_version }}
69 | bundler-cache: true
70 | - name: Configure config database.yml
71 | run: bundle exec rake db:load_credentials
72 | - name: Database Setup
73 | run: bundle exec rake db:test:prepare
74 | - name: Run tests
75 | id: rspec-tests
76 | timeout-minutes: 20
77 | continue-on-error: true
78 | run: |
79 | mkdir -p ./coverage
80 | bundle exec rspec --format progress \
81 | --format RspecJunitFormatter -o ./coverage/test-results.xml \
82 | --profile
83 | - name: Codecov Upload
84 | uses: codecov/codecov-action@v4
85 | with:
86 | token: ${{ secrets.CODECOV_TOKEN }}
87 | verbose: true
88 | disable_search: true
89 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
90 | file: ./coverage/coverage.json
91 | - name: Upload test results to Codecov
92 | uses: codecov/test-results-action@v1
93 | with:
94 | token: ${{ secrets.CODECOV_TOKEN }}
95 | verbose: true
96 | disable_search: true
97 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
98 | file: ./coverage/test-results.xml
99 | - name: Notify of test failure
100 | if: steps.rspec-tests.outcome == 'failure'
101 | run: exit 1
102 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | appraise 'rails-6-1-postgresql' do
4 | gem 'rails', '~> 6.1.0'
5 | gem 'pg', '~> 1.5'
6 | end
7 |
8 | appraise 'rails-6-1-mysql' do
9 | gem 'rails', '~> 6.1.0'
10 | gem 'mysql2', '~> 0.5'
11 | end
12 |
13 | appraise 'rails-6-1-sqlite3' do
14 | gem 'rails', '~> 6.1.0'
15 | gem 'sqlite3', '~> 1.4'
16 | end
17 |
18 | appraise 'rails-6-1-jdbc-postgresql' do
19 | gem 'rails', '~> 6.1.0'
20 | platforms :jruby do
21 | gem 'activerecord-jdbc-adapter', '~> 61.3'
22 | gem 'activerecord-jdbcpostgresql-adapter', '~> 61.3'
23 | gem 'jdbc-postgres'
24 | end
25 | end
26 |
27 | appraise 'rails-6-1-jdbc-mysql' do
28 | gem 'rails', '~> 6.1.0'
29 | platforms :jruby do
30 | gem 'activerecord-jdbc-adapter', '~> 61.3'
31 | gem 'activerecord-jdbcmysql-adapter', '~> 61.3'
32 | gem 'jdbc-mysql'
33 | end
34 | end
35 |
36 | appraise 'rails-6-1-jdbc-sqlite3' do
37 | gem 'rails', '~> 6.1.0'
38 | platforms :jruby do
39 | gem 'activerecord-jdbc-adapter', '~> 61.3'
40 | gem 'activerecord-jdbcsqlite3-adapter', '~> 61.3'
41 | gem 'jdbc-sqlite3'
42 | end
43 | end
44 |
45 | appraise 'rails-7-0-postgresql' do
46 | gem 'rails', '~> 7.0.0'
47 | gem 'pg', '~> 1.5'
48 | end
49 |
50 | appraise 'rails-7-0-mysql' do
51 | gem 'rails', '~> 7.0.0'
52 | gem 'mysql2', '~> 0.5'
53 | end
54 |
55 | appraise 'rails-7-0-sqlite3' do
56 | gem 'rails', '~> 7.0.0'
57 | gem 'sqlite3', '~> 1.4'
58 | end
59 |
60 | appraise 'rails-7-0-jdbc-postgresql' do
61 | gem 'rails', '~> 7.0.0'
62 | platforms :jruby do
63 | gem 'activerecord-jdbc-adapter', '~> 70.0'
64 | gem 'activerecord-jdbcpostgresql-adapter', '~> 70.0'
65 | gem 'jdbc-postgres'
66 | end
67 | end
68 |
69 | appraise 'rails-7-0-jdbc-mysql' do
70 | gem 'rails', '~> 7.0.0'
71 | platforms :jruby do
72 | gem 'activerecord-jdbc-adapter', '~> 70.0'
73 | gem 'activerecord-jdbcmysql-adapter', '~> 70.0'
74 | gem 'jdbc-mysql'
75 | end
76 | end
77 |
78 | appraise 'rails-7-0-jdbc-sqlite3' do
79 | gem 'rails', '~> 7.0.0'
80 | platforms :jruby do
81 | gem 'activerecord-jdbc-adapter', '~> 70.0'
82 | gem 'activerecord-jdbcsqlite3-adapter', '~> 70.0'
83 | gem 'jdbc-sqlite3'
84 | end
85 | end
86 |
87 | appraise 'rails-7-1-postgresql' do
88 | gem 'rails', '~> 7.1.0'
89 | gem 'pg', '~> 1.5'
90 | end
91 |
92 | appraise 'rails-7-1-mysql' do
93 | gem 'rails', '~> 7.1.0'
94 | gem 'mysql2', '~> 0.5'
95 | end
96 |
97 | appraise 'rails-7-1-sqlite3' do
98 | gem 'rails', '~> 7.1.0'
99 | gem 'sqlite3', '~> 2.1'
100 | end
101 |
102 | appraise 'rails-7-2-postgresql' do
103 | gem 'rails', '~> 7.2.0'
104 | gem 'pg', '~> 1.5'
105 | end
106 |
107 | appraise 'rails-7-2-mysql' do
108 | gem 'rails', '~> 7.2.0'
109 | gem 'mysql2', '~> 0.5'
110 | end
111 |
112 | appraise 'rails-7-2-sqlite3' do
113 | gem 'rails', '~> 7.2.0'
114 | gem 'sqlite3', '~> 2.1'
115 | end
116 |
117 | appraise 'rails-8-0-postgresql' do
118 | gem 'rails', '~> 8.0.0'
119 | gem 'pg', '~> 1.5'
120 | end
121 |
122 | appraise 'rails-8-0-mysql' do
123 | gem 'rails', '~> 8.0.0'
124 | gem 'mysql2', '~> 0.5'
125 | end
126 |
127 | appraise 'rails-8-0-sqlite3' do
128 | gem 'rails', '~> 8.0.0'
129 | gem 'sqlite3', '~> 2.1'
130 | end
131 |
132 | appraise 'rails-8-1-postgresql' do
133 | gem 'rails', '~> 8.1.0'
134 | gem 'pg', '~> 1.6.0'
135 | end
136 |
137 | appraise 'rails-8-1-mysql' do
138 | gem 'rails', '~> 8.1.0'
139 | gem 'mysql2', '~> 0.5'
140 | end
141 |
142 | appraise 'rails-8-1-sqlite3' do
143 | gem 'rails', '~> 8.1.0'
144 | gem 'sqlite3', '~> 2.8'
145 | end
146 |
--------------------------------------------------------------------------------
/spec/unit/elevators/host_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'apartment/elevators/host'
5 |
6 | describe Apartment::Elevators::Host do
7 | subject(:elevator) { described_class.new(proc {}) }
8 |
9 | describe '#parse_tenant_name' do
10 | it 'returns nil when no host' do
11 | request = ActionDispatch::Request.new('HTTP_HOST' => '')
12 | expect(elevator.parse_tenant_name(request)).to(be_nil)
13 | end
14 |
15 | context 'when assuming no ignored_first_subdomains' do
16 | before { allow(described_class).to(receive(:ignored_first_subdomains).and_return([])) }
17 |
18 | context 'with 3 parts' do
19 | it 'returns the whole host' do
20 | request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com')
21 | expect(elevator.parse_tenant_name(request)).to(eq('foo.bar.com'))
22 | end
23 | end
24 |
25 | context 'with 6 parts' do
26 | it 'returns the whole host' do
27 | request = ActionDispatch::Request.new('HTTP_HOST' => 'one.two.three.foo.bar.com')
28 | expect(elevator.parse_tenant_name(request)).to(eq('one.two.three.foo.bar.com'))
29 | end
30 | end
31 | end
32 |
33 | context 'when assuming ignored_first_subdomains is set' do
34 | before { allow(described_class).to(receive(:ignored_first_subdomains).and_return(%w[www foo])) }
35 |
36 | context 'with 3 parts' do
37 | it 'returns host without www' do
38 | request = ActionDispatch::Request.new('HTTP_HOST' => 'www.bar.com')
39 | expect(elevator.parse_tenant_name(request)).to(eq('bar.com'))
40 | end
41 |
42 | it 'returns host without foo' do
43 | request = ActionDispatch::Request.new('HTTP_HOST' => 'foo.bar.com')
44 | expect(elevator.parse_tenant_name(request)).to(eq('bar.com'))
45 | end
46 | end
47 |
48 | context 'with 6 parts' do
49 | context 'when ignored subdomains do not match in the begining' do
50 | let(:http_host) { 'www.one.two.three.foo.bar.com' }
51 |
52 | it 'returns host without www' do
53 | request = ActionDispatch::Request.new('HTTP_HOST' => http_host)
54 | expect(elevator.parse_tenant_name(request)).to(eq('one.two.three.foo.bar.com'))
55 | end
56 | end
57 |
58 | context 'when ignored subdomains match in the begining' do
59 | let(:http_host) { 'foo.one.two.three.bar.com' }
60 |
61 | it 'returns host without matching subdomain' do
62 | request = ActionDispatch::Request.new('HTTP_HOST' => http_host)
63 | expect(elevator.parse_tenant_name(request)).to(eq('one.two.three.bar.com'))
64 | end
65 | end
66 | end
67 | end
68 |
69 | context 'when assuming localhost' do
70 | it 'returns localhost' do
71 | request = ActionDispatch::Request.new('HTTP_HOST' => 'localhost')
72 | expect(elevator.parse_tenant_name(request)).to(eq('localhost'))
73 | end
74 | end
75 |
76 | context 'when assuming ip address' do
77 | it 'returns the ip address' do
78 | request = ActionDispatch::Request.new('HTTP_HOST' => '127.0.0.1')
79 | expect(elevator.parse_tenant_name(request)).to(eq('127.0.0.1'))
80 | end
81 | end
82 | end
83 |
84 | describe '#call' do
85 | it 'switches to the proper tenant' do
86 | allow(described_class).to(receive(:ignored_first_subdomains).and_return([]))
87 | expect(Apartment::Tenant).to(receive(:switch).with('foo.bar.com'))
88 | elevator.call('HTTP_HOST' => 'foo.bar.com')
89 | end
90 |
91 | it 'ignores ignored_first_subdomains' do
92 | allow(described_class).to(receive(:ignored_first_subdomains).and_return(%w[foo]))
93 | expect(Apartment::Tenant).to(receive(:switch).with('bar.com'))
94 | elevator.call('HTTP_HOST' => 'foo.bar.com')
95 | end
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | NewCops: enable
3 | Exclude:
4 | - vendor/bundle/**/*
5 | - gemfiles/**/*.gemfile
6 | - gemfiles/vendor/**/*
7 | - spec/dummy_engine/dummy_engine.gemspec
8 | - spec/schemas/**/*.rb
9 |
10 | plugins:
11 | - rubocop-rails
12 | - rubocop-performance
13 | - rubocop-thread_safety
14 | - rubocop-rake
15 | - rubocop-rspec
16 |
17 | Gemspec/RequiredRubyVersion:
18 | Exclude:
19 | - 'ros-apartment.gemspec'
20 |
21 | Layout/MultilineMethodCallIndentation:
22 | EnforcedStyle: indented
23 |
24 | Metrics/BlockLength:
25 | Max: 30
26 | Exclude:
27 | - spec/**/*.rb
28 | - lib/tasks/**/*.rake
29 | - Rakefile
30 |
31 | Metrics/MethodLength:
32 | Max: 15
33 | Exclude:
34 | - spec/**/*.rb
35 | - lib/apartment/tenant.rb
36 | - lib/apartment/migrator.rb
37 |
38 | Metrics/ModuleLength:
39 | Max: 150
40 | Exclude:
41 | - spec/**/*.rb
42 |
43 | Metrics/AbcSize:
44 | Max: 20
45 | Exclude:
46 | - spec/**/*.rb
47 | - lib/apartment/adapters/postgresql_adapter.rb
48 |
49 | Metrics/ClassLength:
50 | Max: 155
51 | Exclude:
52 | - spec/**/*.rb
53 |
54 | Rails/RakeEnvironment:
55 | Enabled: false
56 |
57 | Rails/ApplicationRecord:
58 | Enabled: false
59 |
60 | Rails/Output:
61 | Enabled: false
62 |
63 | Style/Documentation:
64 | Enabled: false
65 |
66 | Style/StringLiterals:
67 | EnforcedStyle: single_quotes
68 |
69 | Style/InlineComment:
70 | Enabled: false
71 |
72 | Style/FrozenStringLiteralComment:
73 | Enabled: true
74 | Exclude:
75 | - Gemfile
76 |
77 | Style/MethodCallWithArgsParentheses:
78 | Enabled: true
79 | EnforcedStyle: require_parentheses
80 | AllowedPatterns:
81 | - 'puts'
82 | - 'info'
83 | - 'warn'
84 | - 'debug'
85 | - 'error'
86 | - 'fatal'
87 | - 'fail'
88 |
89 | Style/TrailingCommaInArrayLiteral:
90 | EnforcedStyleForMultiline: comma
91 |
92 | Style/TrailingCommaInHashLiteral:
93 | EnforcedStyleForMultiline: comma
94 |
95 | Style/ClassAndModuleChildren:
96 | EnforcedStyle: nested
97 | AutoCorrect: true
98 |
99 | Style/CollectionMethods:
100 | PreferredMethods:
101 | collect: 'map'
102 | collect!: 'map!'
103 | inject: 'reduce'
104 | detect: 'detect'
105 | find_all: 'select'
106 |
107 | # RSpec style preferences - disable for mature test suite
108 | RSpec/NamedSubject:
109 | Enabled: false
110 |
111 | RSpec/MultipleExpectations:
112 | Enabled: false
113 |
114 | RSpec/MessageSpies:
115 | Enabled: false
116 |
117 | RSpec/NestedGroups:
118 | Enabled: false
119 |
120 | RSpec/ContextWording:
121 | Enabled: false
122 |
123 | RSpec/ExampleLength:
124 | Enabled: false
125 |
126 | RSpec/InstanceVariable:
127 | Enabled: false
128 |
129 | RSpec/SpecFilePathFormat:
130 | Enabled: false
131 |
132 | RSpec/DescribeClass:
133 | Enabled: false
134 |
135 | RSpec/IndexedLet:
136 | Enabled: false
137 |
138 | RSpec/AnyInstance:
139 | Enabled: false
140 |
141 | RSpec/BeforeAfterAll:
142 | Enabled: false
143 |
144 | RSpec/LeakyConstantDeclaration:
145 | Enabled: false
146 |
147 | RSpec/VerifiedDoubles:
148 | Enabled: false
149 |
150 | RSpec/NoExpectationExample:
151 | Enabled: false
152 |
153 | # ThreadSafety - intentional design for configuration
154 | ThreadSafety/ClassInstanceVariable:
155 | Exclude:
156 | - lib/apartment.rb
157 | - lib/apartment/model.rb
158 | - lib/apartment/elevators/*.rb
159 | - spec/support/config.rb
160 |
161 | ThreadSafety/ClassAndModuleAttributes:
162 | Exclude:
163 | - lib/apartment.rb
164 | - lib/apartment/active_record/postgresql_adapter.rb
165 |
166 | ThreadSafety/DirChdir:
167 | Exclude:
168 | - ros-apartment.gemspec
169 |
170 | ThreadSafety/NewThread:
171 | Exclude:
172 | - spec/tenant_spec.rb
173 |
174 | # Rake cops
175 | Rake/DuplicateTask:
176 | Enabled: false
--------------------------------------------------------------------------------
/.github/workflows/rspec_pg_14.yml:
--------------------------------------------------------------------------------
1 | name: RSpec PostgreSQL 14
2 | on:
3 | push:
4 | branches:
5 | - development
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 | release:
10 | types: [published]
11 |
12 | jobs:
13 | test:
14 | name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby_version:
20 | - 3.1
21 | - 3.2
22 | - 3.3
23 | - 3.4
24 | - jruby
25 | rails_version:
26 | - 7_0
27 | - 7_1
28 | - 7_2
29 | - 8_0
30 | - 8_1
31 | exclude:
32 | - ruby_version: jruby
33 | rails_version: 7_1
34 | - ruby_version: jruby
35 | rails_version: 7_2
36 | - ruby_version: jruby
37 | rails_version: 8_0
38 | - ruby_version: 3.1
39 | rails_version: 8_0
40 | - ruby_version: jruby
41 | rails_version: 8_1
42 | - ruby_version: 3.1
43 | rails_version: 8_1
44 | - ruby_version: 3.1
45 | env:
46 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile
47 | CI: true
48 | DATABASE_ENGINE: postgresql
49 | RUBY_VERSION: ${{ matrix.ruby_version }}
50 | RAILS_VERSION: ${{ matrix.rails_version }}
51 | services:
52 | postgres:
53 | image: postgres:14-alpine
54 | env:
55 | POSTGRES_PASSWORD: postgres
56 | POSTGRES_HOST_AUTH_METHOD: trust
57 | POSTGRES_DB: apartment_postgresql_test
58 | options: >-
59 | --health-cmd pg_isready
60 | --health-interval 10s
61 | --health-timeout 5s
62 | --health-retries 5
63 | ports:
64 | - 5432:5432
65 | steps:
66 | - name: Install PostgreSQL client
67 | run: |
68 | sudo apt-get update -qq
69 | sudo apt-get install -y --no-install-recommends postgresql-common
70 | echo | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
71 | sudo apt-get update -qq
72 | sudo apt-get install -y --no-install-recommends postgresql-client-14
73 | - uses: actions/checkout@v4
74 | - name: Set up Ruby ${{ matrix.ruby-version }}
75 | uses: ruby/setup-ruby@v1
76 | with:
77 | ruby-version: ${{ matrix.ruby_version }}
78 | bundler-cache: true
79 | - name: Configure config database.yml
80 | run: bundle exec rake db:load_credentials
81 | - name: Database Setup
82 | run: bundle exec rake db:test:prepare
83 | - name: Run tests
84 | id: rspec-tests
85 | timeout-minutes: 20
86 | continue-on-error: true
87 | run: |
88 | mkdir -p ./coverage
89 | bundle exec rspec --format progress \
90 | --format RspecJunitFormatter -o ./coverage/test-results.xml \
91 | --profile
92 | - name: Codecov Upload
93 | uses: codecov/codecov-action@v4
94 | with:
95 | token: ${{ secrets.CODECOV_TOKEN }}
96 | verbose: true
97 | disable_search: true
98 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
99 | file: ./coverage/coverage.json
100 | - name: Upload test results to Codecov
101 | uses: codecov/test-results-action@v1
102 | with:
103 | token: ${{ secrets.CODECOV_TOKEN }}
104 | verbose: true
105 | disable_search: true
106 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
107 | file: ./coverage/test-results.xml
108 | - name: Notify of test failure
109 | if: steps.rspec-tests.outcome == 'failure'
110 | run: exit 1
111 |
--------------------------------------------------------------------------------
/.github/workflows/rspec_pg_15.yml:
--------------------------------------------------------------------------------
1 | name: RSpec PostgreSQL 15
2 | on:
3 | push:
4 | branches:
5 | - development
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 | release:
10 | types: [published]
11 |
12 | jobs:
13 | test:
14 | name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby_version:
20 | - 3.1
21 | - 3.2
22 | - 3.3
23 | - 3.4
24 | - jruby
25 | rails_version:
26 | - 7_0
27 | - 7_1
28 | - 7_2
29 | - 8_0
30 | - 8_1
31 | exclude:
32 | - ruby_version: jruby
33 | rails_version: 7_1
34 | - ruby_version: jruby
35 | rails_version: 7_2
36 | - ruby_version: jruby
37 | rails_version: 8_0
38 | - ruby_version: 3.1
39 | rails_version: 8_0
40 | - ruby_version: jruby
41 | rails_version: 8_1
42 | - ruby_version: 3.1
43 | rails_version: 8_1
44 | - ruby_version: 3.1
45 | env:
46 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile
47 | CI: true
48 | DATABASE_ENGINE: postgresql
49 | RUBY_VERSION: ${{ matrix.ruby_version }}
50 | RAILS_VERSION: ${{ matrix.rails_version }}
51 | services:
52 | postgres:
53 | image: postgres:15-alpine
54 | env:
55 | POSTGRES_PASSWORD: postgres
56 | POSTGRES_HOST_AUTH_METHOD: trust
57 | POSTGRES_DB: apartment_postgresql_test
58 | options: >-
59 | --health-cmd pg_isready
60 | --health-interval 10s
61 | --health-timeout 5s
62 | --health-retries 5
63 | ports:
64 | - 5432:5432
65 | steps:
66 | - name: Install PostgreSQL client
67 | run: |
68 | sudo apt-get update -qq
69 | sudo apt-get install -y --no-install-recommends postgresql-common
70 | echo | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
71 | sudo apt-get update -qq
72 | sudo apt-get install -y --no-install-recommends postgresql-client-15
73 | - uses: actions/checkout@v4
74 | - name: Set up Ruby ${{ matrix.ruby-version }}
75 | uses: ruby/setup-ruby@v1
76 | with:
77 | ruby-version: ${{ matrix.ruby_version }}
78 | bundler-cache: true
79 | - name: Configure config database.yml
80 | run: bundle exec rake db:load_credentials
81 | - name: Database Setup
82 | run: bundle exec rake db:test:prepare
83 | - name: Run tests
84 | id: rspec-tests
85 | timeout-minutes: 20
86 | continue-on-error: true
87 | run: |
88 | mkdir -p ./coverage
89 | bundle exec rspec --format progress \
90 | --format RspecJunitFormatter -o ./coverage/test-results.xml \
91 | --profile
92 | - name: Codecov Upload
93 | uses: codecov/codecov-action@v4
94 | with:
95 | token: ${{ secrets.CODECOV_TOKEN }}
96 | verbose: true
97 | disable_search: true
98 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
99 | file: ./coverage/coverage.json
100 | - name: Upload test results to Codecov
101 | uses: codecov/test-results-action@v1
102 | with:
103 | token: ${{ secrets.CODECOV_TOKEN }}
104 | verbose: true
105 | disable_search: true
106 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
107 | file: ./coverage/test-results.xml
108 | - name: Notify of test failure
109 | if: steps.rspec-tests.outcome == 'failure'
110 | run: exit 1
111 |
--------------------------------------------------------------------------------
/.github/workflows/rspec_pg_16.yml:
--------------------------------------------------------------------------------
1 | name: RSpec PostgreSQL 16
2 | on:
3 | push:
4 | branches:
5 | - development
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 | release:
10 | types: [published]
11 |
12 | jobs:
13 | test:
14 | name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby_version:
20 | - 3.1
21 | - 3.2
22 | - 3.3
23 | - 3.4
24 | - jruby
25 | rails_version:
26 | - 7_0
27 | - 7_1
28 | - 7_2
29 | - 8_0
30 | - 8_1
31 | exclude:
32 | - ruby_version: jruby
33 | rails_version: 7_1
34 | - ruby_version: jruby
35 | rails_version: 7_2
36 | - ruby_version: jruby
37 | rails_version: 8_0
38 | - ruby_version: 3.1
39 | rails_version: 8_0
40 | - ruby_version: jruby
41 | rails_version: 8_1
42 | - ruby_version: 3.1
43 | rails_version: 8_1
44 | - ruby_version: 3.1
45 | env:
46 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile
47 | CI: true
48 | DATABASE_ENGINE: postgresql
49 | RUBY_VERSION: ${{ matrix.ruby_version }}
50 | RAILS_VERSION: ${{ matrix.rails_version }}
51 | services:
52 | postgres:
53 | image: postgres:16-alpine
54 | env:
55 | POSTGRES_PASSWORD: postgres
56 | POSTGRES_HOST_AUTH_METHOD: trust
57 | POSTGRES_DB: apartment_postgresql_test
58 | options: >-
59 | --health-cmd pg_isready
60 | --health-interval 10s
61 | --health-timeout 5s
62 | --health-retries 5
63 | ports:
64 | - 5432:5432
65 | steps:
66 | - name: Install PostgreSQL client
67 | run: |
68 | sudo apt-get update -qq
69 | sudo apt-get install -y --no-install-recommends postgresql-common
70 | echo | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
71 | sudo apt-get update -qq
72 | sudo apt-get install -y --no-install-recommends postgresql-client-16
73 | - uses: actions/checkout@v4
74 | - name: Set up Ruby ${{ matrix.ruby-version }}
75 | uses: ruby/setup-ruby@v1
76 | with:
77 | ruby-version: ${{ matrix.ruby_version }}
78 | bundler-cache: true
79 | - name: Configure config database.yml
80 | run: bundle exec rake db:load_credentials
81 | - name: Database Setup
82 | run: bundle exec rake db:test:prepare
83 | - name: Run tests
84 | id: rspec-tests
85 | timeout-minutes: 20
86 | continue-on-error: true
87 | run: |
88 | mkdir -p ./coverage
89 | bundle exec rspec --format progress \
90 | --format RspecJunitFormatter -o ./coverage/test-results.xml \
91 | --profile
92 | - name: Codecov Upload
93 | uses: codecov/codecov-action@v4
94 | with:
95 | token: ${{ secrets.CODECOV_TOKEN }}
96 | verbose: true
97 | disable_search: true
98 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
99 | file: ./coverage/coverage.json
100 | - name: Upload test results to Codecov
101 | uses: codecov/test-results-action@v1
102 | with:
103 | token: ${{ secrets.CODECOV_TOKEN }}
104 | verbose: true
105 | disable_search: true
106 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
107 | file: ./coverage/test-results.xml
108 | - name: Notify of test failure
109 | if: steps.rspec-tests.outcome == 'failure'
110 | run: exit 1
111 |
--------------------------------------------------------------------------------
/.github/workflows/rspec_pg_17.yml:
--------------------------------------------------------------------------------
1 | name: RSpec PostgreSQL 17
2 | on:
3 | push:
4 | branches:
5 | - development
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 | release:
10 | types: [published]
11 |
12 | jobs:
13 | test:
14 | name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby_version:
20 | - 3.1
21 | - 3.2
22 | - 3.3
23 | - 3.4
24 | - jruby
25 | rails_version:
26 | - 7_0
27 | - 7_1
28 | - 7_2
29 | - 8_0
30 | - 8_1
31 | exclude:
32 | - ruby_version: jruby
33 | rails_version: 7_1
34 | - ruby_version: jruby
35 | rails_version: 7_2
36 | - ruby_version: jruby
37 | rails_version: 8_0
38 | - ruby_version: 3.1
39 | rails_version: 8_0
40 | - ruby_version: jruby
41 | rails_version: 8_1
42 | - ruby_version: 3.1
43 | rails_version: 8_1
44 | - ruby_version: 3.1
45 | env:
46 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile
47 | CI: true
48 | DATABASE_ENGINE: postgresql
49 | RUBY_VERSION: ${{ matrix.ruby_version }}
50 | RAILS_VERSION: ${{ matrix.rails_version }}
51 | services:
52 | postgres:
53 | image: postgres:17-alpine
54 | env:
55 | POSTGRES_PASSWORD: postgres
56 | POSTGRES_HOST_AUTH_METHOD: trust
57 | POSTGRES_DB: apartment_postgresql_test
58 | options: >-
59 | --health-cmd pg_isready
60 | --health-interval 10s
61 | --health-timeout 5s
62 | --health-retries 5
63 | ports:
64 | - 5432:5432
65 | steps:
66 | - name: Install PostgreSQL client
67 | run: |
68 | sudo apt-get update -qq
69 | sudo apt-get install -y --no-install-recommends postgresql-common
70 | echo | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
71 | sudo apt-get update -qq
72 | sudo apt-get install -y --no-install-recommends postgresql-client-17
73 | - uses: actions/checkout@v4
74 | - name: Set up Ruby ${{ matrix.ruby-version }}
75 | uses: ruby/setup-ruby@v1
76 | with:
77 | ruby-version: ${{ matrix.ruby_version }}
78 | bundler-cache: true
79 | - name: Configure config database.yml
80 | run: bundle exec rake db:load_credentials
81 | - name: Database Setup
82 | run: bundle exec rake db:test:prepare
83 | - name: Run tests
84 | id: rspec-tests
85 | timeout-minutes: 20
86 | continue-on-error: true
87 | run: |
88 | mkdir -p ./coverage
89 | bundle exec rspec --format progress \
90 | --format RspecJunitFormatter -o ./coverage/test-results.xml \
91 | --profile
92 | - name: Codecov Upload
93 | uses: codecov/codecov-action@v4
94 | with:
95 | token: ${{ secrets.CODECOV_TOKEN }}
96 | verbose: true
97 | disable_search: true
98 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
99 | file: ./coverage/coverage.json
100 | - name: Upload test results to Codecov
101 | uses: codecov/test-results-action@v1
102 | with:
103 | token: ${{ secrets.CODECOV_TOKEN }}
104 | verbose: true
105 | disable_search: true
106 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
107 | file: ./coverage/test-results.xml
108 | - name: Notify of test failure
109 | if: steps.rspec-tests.outcome == 'failure'
110 | run: exit 1
111 |
--------------------------------------------------------------------------------
/.github/workflows/rspec_pg_18.yml:
--------------------------------------------------------------------------------
1 | name: RSpec PostgreSQL 18
2 | on:
3 | push:
4 | branches:
5 | - development
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 | release:
10 | types: [published]
11 |
12 | jobs:
13 | test:
14 | name: ${{ github.workflow }}, Ruby ${{ matrix.ruby_version }}, Rails ${{ matrix.rails_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby_version:
20 | - 3.1
21 | - 3.2
22 | - 3.3
23 | - 3.4
24 | - jruby
25 | rails_version:
26 | - 7_0
27 | - 7_1
28 | - 7_2
29 | - 8_0
30 | - 8_1
31 | exclude:
32 | - ruby_version: jruby
33 | rails_version: 7_1
34 | - ruby_version: jruby
35 | rails_version: 7_2
36 | - ruby_version: jruby
37 | rails_version: 8_0
38 | - ruby_version: 3.1
39 | rails_version: 8_0
40 | - ruby_version: jruby
41 | rails_version: 8_1
42 | - ruby_version: 3.1
43 | rails_version: 8_1
44 | - ruby_version: 3.1
45 | env:
46 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails_version }}${{ matrix.ruby_version == 'jruby' && '_jdbc' || '' }}_postgresql.gemfile
47 | CI: true
48 | DATABASE_ENGINE: postgresql
49 | RUBY_VERSION: ${{ matrix.ruby_version }}
50 | RAILS_VERSION: ${{ matrix.rails_version }}
51 | services:
52 | postgres:
53 | image: postgres:18-alpine
54 | env:
55 | POSTGRES_PASSWORD: postgres
56 | POSTGRES_HOST_AUTH_METHOD: trust
57 | POSTGRES_DB: apartment_postgresql_test
58 | options: >-
59 | --health-cmd pg_isready
60 | --health-interval 10s
61 | --health-timeout 5s
62 | --health-retries 5
63 | ports:
64 | - 5432:5432
65 | steps:
66 | - name: Install PostgreSQL client
67 | run: |
68 | sudo apt-get update -qq
69 | sudo apt-get install -y --no-install-recommends postgresql-common
70 | echo | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
71 | sudo apt-get update -qq
72 | sudo apt-get install -y --no-install-recommends postgresql-client-18
73 | - uses: actions/checkout@v4
74 | - name: Set up Ruby ${{ matrix.ruby-version }}
75 | uses: ruby/setup-ruby@v1
76 | with:
77 | ruby-version: ${{ matrix.ruby_version }}
78 | bundler-cache: true
79 | - name: Configure config database.yml
80 | run: bundle exec rake db:load_credentials
81 | - name: Database Setup
82 | run: bundle exec rake db:test:prepare
83 | - name: Run tests
84 | id: rspec-tests
85 | timeout-minutes: 20
86 | continue-on-error: true
87 | run: |
88 | mkdir -p ./coverage
89 | bundle exec rspec --format progress \
90 | --format RspecJunitFormatter -o ./coverage/test-results.xml \
91 | --profile
92 | - name: Codecov Upload
93 | uses: codecov/codecov-action@v4
94 | with:
95 | token: ${{ secrets.CODECOV_TOKEN }}
96 | verbose: true
97 | disable_search: true
98 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
99 | file: ./coverage/coverage.json
100 | - name: Upload test results to Codecov
101 | uses: codecov/test-results-action@v1
102 | with:
103 | token: ${{ secrets.CODECOV_TOKEN }}
104 | verbose: true
105 | disable_search: true
106 | env_vars: DATABASE_ENGINE, RUBY_VERSION, RAILS_VERSION
107 | file: ./coverage/test-results.xml
108 | - name: Notify of test failure
109 | if: steps.rspec-tests.outcome == 'failure'
110 | run: exit 1
111 |
--------------------------------------------------------------------------------
/spec/tasks/apartment_rake_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'rake'
5 | require 'apartment/migrator'
6 | require 'apartment/tenant'
7 |
8 | describe 'apartment rake tasks' do
9 | before do
10 | @rake = Rake::Application.new
11 | Rake.application = @rake
12 | load 'tasks/apartment.rake'
13 | # stub out rails tasks
14 | Rake::Task.define_task('environment')
15 | Rake::Task.define_task('db:migrate')
16 | Rake::Task.define_task('db:seed')
17 | Rake::Task.define_task('db:rollback')
18 | Rake::Task.define_task('db:migrate:up')
19 | Rake::Task.define_task('db:migrate:down')
20 | Rake::Task.define_task('db:migrate:redo')
21 | end
22 |
23 | after do
24 | Rake.application = nil
25 | ENV['VERSION'] = nil # linux users reported env variable carrying on between tests
26 | end
27 |
28 | after(:all) do
29 | Apartment::Test.load_schema
30 | end
31 |
32 | let(:version) { '1234' }
33 |
34 | context 'database migration' do
35 | let(:tenant_names) { Array(3).map { Apartment::Test.next_db } }
36 | let(:tenant_count) { tenant_names.length }
37 |
38 | before do
39 | allow(Apartment).to(receive(:tenant_names).and_return(tenant_names))
40 | end
41 |
42 | describe 'apartment:migrate' do
43 | before do
44 | allow(ActiveRecord::Migrator).to(receive(:migrate)) # don't care about this
45 | end
46 |
47 | it 'migrates public and all multi-tenant dbs' do
48 | expect(Apartment::Migrator).to(receive(:migrate).exactly(tenant_count).times)
49 | @rake['apartment:migrate'].invoke
50 | end
51 | end
52 |
53 | describe 'apartment:migrate:up' do
54 | context 'without a version' do
55 | before do
56 | ENV['VERSION'] = nil
57 | end
58 |
59 | it 'requires a version to migrate to' do
60 | expect do
61 | @rake['apartment:migrate:up'].invoke
62 | end.to(raise_error('VERSION is required'))
63 | end
64 | end
65 |
66 | context 'with version' do
67 | before do
68 | ENV['VERSION'] = version
69 | end
70 |
71 | it 'migrates up to a specific version' do
72 | expect(Apartment::Migrator).to(receive(:run).with(:up, anything, version.to_i).exactly(tenant_count).times)
73 | @rake['apartment:migrate:up'].invoke
74 | end
75 | end
76 | end
77 |
78 | describe 'apartment:migrate:down' do
79 | context 'without a version' do
80 | before do
81 | ENV['VERSION'] = nil
82 | end
83 |
84 | it 'requires a version to migrate to' do
85 | expect do
86 | @rake['apartment:migrate:down'].invoke
87 | end.to(raise_error('VERSION is required'))
88 | end
89 | end
90 |
91 | context 'with version' do
92 | before do
93 | ENV['VERSION'] = version
94 | end
95 |
96 | it 'migrates up to a specific version' do
97 | expect(Apartment::Migrator).to(receive(:run).with(:down, anything, version.to_i).exactly(tenant_count).times)
98 | @rake['apartment:migrate:down'].invoke
99 | end
100 | end
101 | end
102 |
103 | describe 'apartment:rollback' do
104 | let(:step) { '3' }
105 |
106 | it 'rollbacks dbs' do
107 | expect(Apartment::Migrator).to(receive(:rollback).exactly(tenant_count).times)
108 | @rake['apartment:rollback'].invoke
109 | end
110 |
111 | it 'rollbacks dbs STEP amt' do
112 | expect(Apartment::Migrator).to(receive(:rollback).with(anything, step.to_i).exactly(tenant_count).times)
113 | ENV['STEP'] = step
114 | @rake['apartment:rollback'].invoke
115 | end
116 | end
117 |
118 | describe 'apartment:drop' do
119 | it 'migrates public and all multi-tenant dbs' do
120 | expect(Apartment::Tenant).to(receive(:drop).exactly(tenant_count).times)
121 | @rake['apartment:drop'].invoke
122 | end
123 | end
124 | end
125 | end
126 |
--------------------------------------------------------------------------------
/spec/unit/config_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | describe Apartment do
6 | describe '#config' do
7 | let(:excluded_models) { ['Company'] }
8 | let(:seed_data_file_path) { Rails.root.join('db/seeds/import.rb') }
9 |
10 | def tenant_names_from_array(names)
11 | names.index_with do |_tenant|
12 | Apartment.connection_config
13 | end.with_indifferent_access
14 | end
15 |
16 | it 'yields the Apartment object' do
17 | described_class.configure do |config|
18 | config.excluded_models = []
19 | expect(config).to(eq(described_class))
20 | end
21 | end
22 |
23 | it 'sets excluded models' do
24 | described_class.configure do |config|
25 | config.excluded_models = excluded_models
26 | end
27 | expect(described_class.excluded_models).to(eq(excluded_models))
28 | end
29 |
30 | it 'sets use_schemas' do
31 | described_class.configure do |config|
32 | config.excluded_models = []
33 | config.use_schemas = false
34 | end
35 | expect(described_class.use_schemas).to(be(false))
36 | end
37 |
38 | it 'sets seed_data_file' do
39 | described_class.configure do |config|
40 | config.seed_data_file = seed_data_file_path
41 | end
42 | expect(described_class.seed_data_file).to(eq(seed_data_file_path))
43 | end
44 |
45 | it 'sets seed_after_create' do
46 | described_class.configure do |config|
47 | config.excluded_models = []
48 | config.seed_after_create = true
49 | end
50 | expect(described_class.seed_after_create).to(be(true))
51 | end
52 |
53 | it 'sets tenant_presence_check' do
54 | described_class.configure do |config|
55 | config.tenant_presence_check = true
56 | end
57 | expect(described_class.tenant_presence_check).to(be(true))
58 | end
59 |
60 | it 'sets active_record_log' do
61 | described_class.configure do |config|
62 | config.active_record_log = true
63 | end
64 | expect(described_class.active_record_log).to(be(true))
65 | end
66 |
67 | context 'when databases' do
68 | let(:users_conf_hash) { { port: 5444 } }
69 |
70 | before do
71 | described_class.configure do |config|
72 | config.tenant_names = tenant_names
73 | end
74 | end
75 |
76 | context 'when tenant_names as string array' do
77 | let(:tenant_names) { %w[users companies] }
78 |
79 | it 'returns object if it doesnt respond_to call' do
80 | expect(described_class.tenant_names).to(eq(tenant_names_from_array(tenant_names).keys))
81 | end
82 |
83 | it 'sets tenants_with_config' do
84 | expect(described_class.tenants_with_config).to(eq(tenant_names_from_array(tenant_names)))
85 | end
86 | end
87 |
88 | context 'when tenant_names as proc returning an array' do
89 | let(:tenant_names) { -> { %w[users companies] } }
90 |
91 | it 'returns object if it doesnt respond_to call' do
92 | expect(described_class.tenant_names).to(eq(tenant_names_from_array(tenant_names.call).keys))
93 | end
94 |
95 | it 'sets tenants_with_config' do
96 | expect(described_class.tenants_with_config).to(eq(tenant_names_from_array(tenant_names.call)))
97 | end
98 | end
99 |
100 | context 'when tenant_names as Hash' do
101 | let(:tenant_names) { { users: users_conf_hash }.with_indifferent_access }
102 |
103 | it 'returns object if it doesnt respond_to call' do
104 | expect(described_class.tenant_names).to(eq(tenant_names.keys))
105 | end
106 |
107 | it 'sets tenants_with_config' do
108 | expect(described_class.tenants_with_config).to(eq(tenant_names))
109 | end
110 | end
111 |
112 | context 'when tenant_names as proc returning a Hash' do
113 | let(:tenant_names) { -> { { users: users_conf_hash }.with_indifferent_access } }
114 |
115 | it 'returns object if it doesnt respond_to call' do
116 | expect(described_class.tenant_names).to(eq(tenant_names.call.keys))
117 | end
118 |
119 | it 'sets tenants_with_config' do
120 | expect(described_class.tenants_with_config).to(eq(tenant_names.call))
121 | end
122 | end
123 | end
124 | end
125 | end
126 |
--------------------------------------------------------------------------------
/lib/apartment/tasks/enhancements.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Require this file to append Apartment rake tasks to ActiveRecord db rake tasks
4 | # Enabled by default in the initializer
5 | #
6 | # ## Multi-Database Support (Rails 7+)
7 | #
8 | # When a Rails app has multiple databases configured in database.yml, Rails creates
9 | # namespaced rake tasks like `db:migrate:primary`, `db:rollback:primary`, etc.
10 | # This enhancer automatically detects databases with `database_tasks: true` and
11 | # enhances their namespaced tasks to also run the corresponding apartment task.
12 | #
13 | # Example: Running `rails db:rollback:primary` will also invoke `apartment:rollback`
14 | # to rollback all tenant schemas.
15 |
16 | module Apartment
17 | class RakeTaskEnhancer
18 | module TASKS
19 | ENHANCE_BEFORE = %w[db:drop].freeze
20 | ENHANCE_AFTER = %w[db:migrate db:rollback db:migrate:up db:migrate:down db:migrate:redo db:seed].freeze
21 |
22 | # Base tasks that have namespaced variants in multi-database setups
23 | # db:seed is excluded because Rails doesn't create db:seed:primary
24 | NAMESPACED_AFTER = %w[db:migrate db:rollback db:migrate:up db:migrate:down db:migrate:redo].freeze
25 | freeze
26 | end
27 |
28 | # This is a bit convoluted, but helps solve problems when using Apartment within an engine
29 | # See spec/integration/use_within_an_engine.rb
30 |
31 | class << self
32 | def enhance!
33 | return unless should_enhance?
34 |
35 | enhance_base_tasks!
36 | enhance_namespaced_tasks!
37 | end
38 |
39 | def should_enhance?
40 | Apartment.db_migrate_tenants
41 | end
42 |
43 | private
44 |
45 | # Enhance standard db:* tasks (backward compatible behavior)
46 | def enhance_base_tasks!
47 | TASKS::ENHANCE_BEFORE.each do |name|
48 | enhance_task_before(name)
49 | end
50 |
51 | TASKS::ENHANCE_AFTER.each do |name|
52 | enhance_task_after(name)
53 | end
54 | end
55 |
56 | # Enhance namespaced db:*:database_name tasks for multi-database setups
57 | # Maps namespaced tasks to base apartment tasks:
58 | # db:migrate:primary -> apartment:migrate
59 | # db:rollback:primary -> apartment:rollback
60 | # db:migrate:up:primary -> apartment:migrate:up
61 | def enhance_namespaced_tasks!
62 | database_names_with_tasks.each do |db_name|
63 | TASKS::NAMESPACED_AFTER.each do |base_task|
64 | namespaced_task = "#{base_task}:#{db_name}"
65 | next unless task_defined?(namespaced_task)
66 |
67 | apartment_task = base_task.sub('db:', 'apartment:')
68 | enhance_namespaced_task_after(namespaced_task, apartment_task)
69 | end
70 | end
71 | end
72 |
73 | def enhance_task_before(name)
74 | return unless task_defined?(name)
75 |
76 | task = Rake::Task[name]
77 | task.enhance([inserted_task_name(task)])
78 | end
79 |
80 | def enhance_task_after(name)
81 | return unless task_defined?(name)
82 |
83 | task = Rake::Task[name]
84 | task.enhance do
85 | Rake::Task[inserted_task_name(task)].invoke
86 | end
87 | end
88 |
89 | def enhance_namespaced_task_after(namespaced_task_name, apartment_task_name)
90 | Rake::Task[namespaced_task_name].enhance do
91 | Rake::Task[apartment_task_name].invoke
92 | end
93 | end
94 |
95 | def inserted_task_name(task)
96 | task.name.sub('db:', 'apartment:')
97 | end
98 |
99 | def task_defined?(name)
100 | Rake::Task.task_defined?(name)
101 | end
102 |
103 | # Returns database names that have database_tasks enabled and are not replicas.
104 | # These are the databases for which Rails creates namespaced rake tasks.
105 | #
106 | # @return [Array] database names (e.g., ['primary', 'secondary'])
107 | def database_names_with_tasks
108 | return [] unless defined?(Rails) && Rails.respond_to?(:env)
109 |
110 | configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
111 | configs
112 | .select { |c| c.database_tasks? && !c.replica? }
113 | .map(&:name)
114 | rescue StandardError
115 | # Fail gracefully if configurations unavailable (e.g., during early boot)
116 | []
117 | end
118 | end
119 | end
120 | end
121 |
122 | Apartment::RakeTaskEnhancer.enhance!
123 |
--------------------------------------------------------------------------------
/lib/tasks/apartment.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'apartment/migrator'
4 | require 'apartment/tasks/task_helper'
5 | require 'apartment/tasks/schema_dumper'
6 | require 'parallel'
7 |
8 | apartment_namespace = namespace(:apartment) do
9 | desc('Create all tenants')
10 | task(create: :environment) do
11 | Apartment::TaskHelper.warn_if_tenants_empty
12 |
13 | Apartment::TaskHelper.tenants.each do |tenant|
14 | Apartment::TaskHelper.create_tenant(tenant)
15 | end
16 | end
17 |
18 | desc('Drop all tenants')
19 | task(drop: :environment) do
20 | Apartment::TaskHelper.tenants.each do |tenant|
21 | puts("Dropping #{tenant} tenant")
22 | Apartment::Tenant.drop(tenant)
23 | rescue Apartment::TenantNotFound, ActiveRecord::NoDatabaseError => e
24 | puts e.message
25 | end
26 | end
27 |
28 | desc('Migrate all tenants')
29 | task(migrate: :environment) do
30 | Apartment::TaskHelper.warn_if_tenants_empty
31 |
32 | results = Apartment::TaskHelper.each_tenant do |tenant|
33 | Apartment::TaskHelper.migrate_tenant(tenant)
34 | end
35 |
36 | Apartment::TaskHelper.display_summary('Migration', results)
37 |
38 | # Dump schema after successful migrations
39 | if results.all?(&:success)
40 | Apartment::Tasks::SchemaDumper.dump_if_enabled
41 | else
42 | puts '[Apartment] Skipping schema dump due to migration failures'
43 | end
44 |
45 | # Exit with non-zero status if any tenant failed
46 | exit(1) if results.any? { |r| !r.success }
47 | end
48 |
49 | desc('Seed all tenants')
50 | task(seed: :environment) do
51 | Apartment::TaskHelper.warn_if_tenants_empty
52 |
53 | Apartment::TaskHelper.each_tenant do |tenant|
54 | Apartment::TaskHelper.create_tenant(tenant)
55 | puts("Seeding #{tenant} tenant")
56 | Apartment::Tenant.switch(tenant) do
57 | Apartment::Tenant.seed
58 | end
59 | rescue Apartment::TenantNotFound => e
60 | puts e.message
61 | end
62 | end
63 |
64 | desc('Rolls the migration back to the previous version (specify steps w/ STEP=n) across all tenants.')
65 | task(rollback: :environment) do
66 | Apartment::TaskHelper.warn_if_tenants_empty
67 |
68 | step = ENV.fetch('STEP', '1').to_i
69 |
70 | results = Apartment::TaskHelper.each_tenant do |tenant|
71 | puts("Rolling back #{tenant} tenant")
72 | Apartment::Migrator.rollback(tenant, step)
73 | end
74 |
75 | Apartment::TaskHelper.display_summary('Rollback', results)
76 |
77 | # Dump schema after successful rollback
78 | if results.all?(&:success)
79 | Apartment::Tasks::SchemaDumper.dump_if_enabled
80 | else
81 | puts '[Apartment] Skipping schema dump due to rollback failures'
82 | end
83 |
84 | exit(1) if results.any? { |r| !r.success }
85 | end
86 |
87 | namespace(:migrate) do
88 | desc('Runs the "up" for a given migration VERSION across all tenants.')
89 | task(up: :environment) do
90 | Apartment::TaskHelper.warn_if_tenants_empty
91 |
92 | version = ENV.fetch('VERSION', nil)&.to_i
93 | raise('VERSION is required') unless version
94 |
95 | results = Apartment::TaskHelper.each_tenant do |tenant|
96 | puts("Migrating #{tenant} tenant up")
97 | Apartment::Migrator.run(:up, tenant, version)
98 | end
99 |
100 | Apartment::TaskHelper.display_summary('Migrate Up', results)
101 | Apartment::Tasks::SchemaDumper.dump_if_enabled if results.all?(&:success)
102 | exit(1) if results.any? { |r| !r.success }
103 | end
104 |
105 | desc('Runs the "down" for a given migration VERSION across all tenants.')
106 | task(down: :environment) do
107 | Apartment::TaskHelper.warn_if_tenants_empty
108 |
109 | version = ENV.fetch('VERSION', nil)&.to_i
110 | raise('VERSION is required') unless version
111 |
112 | results = Apartment::TaskHelper.each_tenant do |tenant|
113 | puts("Migrating #{tenant} tenant down")
114 | Apartment::Migrator.run(:down, tenant, version)
115 | end
116 |
117 | Apartment::TaskHelper.display_summary('Migrate Down', results)
118 | Apartment::Tasks::SchemaDumper.dump_if_enabled if results.all?(&:success)
119 | exit(1) if results.any? { |r| !r.success }
120 | end
121 |
122 | desc('Rolls back the tenant one migration and re migrate up (options: STEP=x, VERSION=x).')
123 | task(:redo) do
124 | if ENV.fetch('VERSION', nil)
125 | apartment_namespace['migrate:down'].invoke
126 | apartment_namespace['migrate:up'].invoke
127 | else
128 | apartment_namespace['rollback'].invoke
129 | apartment_namespace['migrate'].invoke
130 | end
131 | end
132 | end
133 | end
134 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | 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.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to a positive environment for our community include:
12 |
13 | - Demonstrating empathy and kindness toward other people
14 | - Being respectful of differing opinions, viewpoints, and experiences
15 | - Giving and gracefully accepting constructive feedback
16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | - Focusing on what is best not just for us as individuals, but for the overall community
18 |
19 | Examples of unacceptable behavior include:
20 |
21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind
22 | - Trolling, insulting or derogatory comments, and personal or political attacks
23 | - Public or private harassment
24 | - Publishing others’ private information, such as a physical or email address, without their explicit permission
25 | - Other conduct which could reasonably be considered inappropriate in a professional setting
26 |
27 | ## Enforcement Responsibilities
28 |
29 | 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.
30 |
31 | 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.
32 |
33 | ## Scope
34 |
35 | 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.
36 |
37 | ## Enforcement
38 |
39 | 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.
40 |
41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
42 |
43 | ## Enforcement Guidelines
44 |
45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
46 |
47 | ### 1. Correction
48 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
49 |
50 | **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.
51 |
52 | ### 2. Warning
53 | **Community Impact**: A violation through a single incident or series of actions.
54 |
55 | **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.
56 |
57 | ### 3. Temporary Ban
58 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
59 |
60 | **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.
61 |
62 | ### 4. Permanent Ban
63 | **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.
64 |
65 | **Consequence**: A permanent ban from any sort of public interaction within the community.
66 |
67 | ## Attribution
68 |
69 | 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.
70 |
71 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq.
--------------------------------------------------------------------------------