├── .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. --------------------------------------------------------------------------------