diff --git a/README.md b/README.md index e08b8f60..10406b64 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,33 @@ and test environments. If you wish to turn this option off in production, you c config.prepend_environment = !Rails.env.production? ``` +## Tenants on different servers + +You can store your tenants in different databases on one or more servers. +To do it, specify your `tenant_names` as a hash, keys being the actual tenant names, +values being a hash with the database configuration to use. + +Example: + +```ruby +config.tenant_names = { + 'tenant1' => { + adapter: 'postgresql', + host: 'some_server', + port: 5555, + database: 'postgres' # this is not the name of the tenant's db + # but the name of the database to connect to, before creating the tenant's db + # mandatory in postgresql + } +} +# or using a lambda: +config.tenant_names = lambda do + Tenant.all.each_with_object({}) do |tenant, hash| + hash[tenant.name] = tenant.db_configuration + end +end +``` + ## Delayed::Job ### Has been removed... See apartment-sidekiq for a better backgrounding experience diff --git a/lib/apartment.rb b/lib/apartment.rb index e70a8e3d..16d4e4d8 100644 --- a/lib/apartment.rb +++ b/lib/apartment.rb @@ -24,9 +24,16 @@ def configure yield self if block_given? end - # Be careful not to use `return` here so both Proc and lambda can be used without breaking def tenant_names - @tenant_names.respond_to?(:call) ? @tenant_names.call : @tenant_names + extract_tenant_config.keys.map(&:to_s) + end + + def tenants_with_config + extract_tenant_config + end + + def db_config_for(tenant) + (tenants_with_config[tenant] || connection_config).with_indifferent_access end # Whether or not db:migrate should also migrate tenants @@ -96,6 +103,19 @@ def use_postgres_schemas=(to_use_or_not_to_use) Apartment::Deprecation.warn "[Deprecation Warning] `use_postgresql_schemas=` is now deprecated, please use `use_schemas=`" self.use_schemas = to_use_or_not_to_use end + + def extract_tenant_config + return {} unless @tenant_names + values = @tenant_names.respond_to?(:call) ? @tenant_names.call : @tenant_names + unless values.is_a? Hash + values = values.each_with_object({}) do |tenant, hash| + hash[tenant] = connection_config + end + end + values.with_indifferent_access + rescue ActiveRecord::StatementInvalid + {} + end end # Exceptions diff --git a/lib/apartment/adapters/abstract_adapter.rb b/lib/apartment/adapters/abstract_adapter.rb index 8cfbe684..7486a3a1 100644 --- a/lib/apartment/adapters/abstract_adapter.rb +++ b/lib/apartment/adapters/abstract_adapter.rb @@ -67,11 +67,12 @@ def default_tenant # @param {String} tenant name # def drop(tenant) - # Apartment.connection.drop_database note that drop_database will not throw an exception, so manually execute - Apartment.connection.execute("DROP DATABASE #{environmentify(tenant)}" ) + with_neutral_connection(tenant) do |conn| + drop_command(conn, tenant) + end - rescue *rescuable_exceptions - raise TenantNotFound, "The tenant #{environmentify(tenant)} cannot be found" + rescue *rescuable_exceptions => exception + raise_drop_tenant_error!(tenant, exception) end # Switch to a new tenant @@ -125,7 +126,7 @@ def each(tenants = Apartment.tenant_names) def process_excluded_models # All other models will shared a connection (at Apartment.connection_class) and we can modify at will Apartment.excluded_models.each do |excluded_model| - excluded_model.constantize.establish_connection @config + process_excluded_model(excluded_model) end end @@ -145,15 +146,32 @@ def seed_data protected + def process_excluded_model(excluded_model) + excluded_model.constantize.establish_connection @config + end + + def drop_command(conn, tenant) + # connection.drop_database note that drop_database will not throw an exception, so manually execute + conn.execute("DROP DATABASE #{environmentify(tenant)}") + end + + class SeparateDbConnectionHandler < ::ActiveRecord::Base + end + # Create the tenant # # @param {String} tenant Database name # def create_tenant(tenant) - Apartment.connection.create_database( environmentify(tenant) ) + with_neutral_connection(tenant) do |conn| + create_tenant_command(conn, tenant) + end + rescue *rescuable_exceptions => exception + raise_create_tenant_error!(tenant, exception) + end - rescue *rescuable_exceptions - raise TenantExists, "The tenant #{environmentify(tenant)} already exists." + def create_tenant_command(conn, tenant) + conn.create_database(environmentify(tenant)) end # Connect to new tenant @@ -163,9 +181,9 @@ def create_tenant(tenant) def connect_to_new(tenant) Apartment.establish_connection multi_tenantify(tenant) Apartment.connection.active? # call active? to manually check if this connection is valid - - rescue *rescuable_exceptions - raise TenantNotFound, "The tenant #{environmentify(tenant)} cannot be found." + rescue *rescuable_exceptions => exception + Apartment::Tenant.reset if reset_on_connection_exception? + raise_connect_error!(tenant, exception) end # Prepend the environment if configured and the environment isn't already there @@ -196,13 +214,21 @@ def import_database_schema end # Return a new config that is multi-tenanted - # - def multi_tenantify(tenant) - @config.clone.tap do |config| - config[:database] = environmentify(tenant) + # @param {String} tenant: Database name + # @param {Boolean} with_database: if true, use the actual tenant's db name + # if false, use the default db name from the db + def multi_tenantify(tenant, with_database = true) + db_connection_config(tenant).tap do |config| + if with_database + multi_tenantify_with_tenant_db_name(config, tenant) + end end end + def multi_tenantify_with_tenant_db_name(config, tenant) + config[:database] = environmentify(tenant) + end + # Load a file or abort if it doesn't exists # def load_or_abort(file) @@ -224,6 +250,34 @@ def rescuable_exceptions def rescue_from [] end + + def db_connection_config(tenant) + Apartment.db_config_for(tenant).clone + end + + # neutral connection is necessary whenever you need to create/remove a database from a server. + # example: when you use postgresql, you need to connect to the default postgresql database before you create your own. + def with_neutral_connection(tenant, &block) + SeparateDbConnectionHandler.establish_connection(multi_tenantify(tenant, false)) + yield(SeparateDbConnectionHandler.connection) + SeparateDbConnectionHandler.connection.close + end + + def reset_on_connection_exception? + false + end + + def raise_drop_tenant_error!(tenant, exception) + raise TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{ exception.message }" + end + + def raise_create_tenant_error!(tenant, exception) + raise TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{ exception.message }" + end + + def raise_connect_error!(tenant, exception) + raise TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{ exception.message }" + end end end end diff --git a/lib/apartment/adapters/abstract_jdbc_adapter.rb b/lib/apartment/adapters/abstract_jdbc_adapter.rb index ac708c73..c4f7f32f 100644 --- a/lib/apartment/adapters/abstract_jdbc_adapter.rb +++ b/lib/apartment/adapters/abstract_jdbc_adapter.rb @@ -4,20 +4,15 @@ module Apartment module Adapters class AbstractJDBCAdapter < AbstractAdapter - protected + private - # Return a new config that is multi-tenanted - # - def multi_tenantify(database) - @config.clone.tap do |config| - config[:url] = "#{config[:url].gsub(/(\S+)\/.+$/, '\1')}/#{environmentify(database)}" - end + def multi_tenantify_with_tenant_db_name(config, tenant) + config[:url] = "#{config[:url].gsub(/(\S+)\/.+$/, '\1')}/#{environmentify(tenant)}" end - private def rescue_from ActiveRecord::JDBCError end end end -end \ No newline at end of file +end diff --git a/lib/apartment/adapters/jdbc_mysql_adapter.rb b/lib/apartment/adapters/jdbc_mysql_adapter.rb index e5fd99db..53fc7dea 100644 --- a/lib/apartment/adapters/jdbc_mysql_adapter.rb +++ b/lib/apartment/adapters/jdbc_mysql_adapter.rb @@ -11,19 +11,8 @@ def self.jdbc_mysql_adapter(config) module Adapters class JDBCMysqlAdapter < AbstractJDBCAdapter - protected - - # Connect to new database - # Abstract adapter will catch generic ActiveRecord error - # Catch specific adapter errors here - # - # @param {String} database Database name - # - def connect_to_new(database) - super - rescue TenantNotFound - Apartment::Tenant.reset - raise TenantNotFound, "Cannot find database #{environmentify(database)}" + def reset_on_connection_exception? + true end end end diff --git a/lib/apartment/adapters/jdbc_postgresql_adapter.rb b/lib/apartment/adapters/jdbc_postgresql_adapter.rb index d4523a79..f0922d8c 100644 --- a/lib/apartment/adapters/jdbc_postgresql_adapter.rb +++ b/lib/apartment/adapters/jdbc_postgresql_adapter.rb @@ -15,27 +15,16 @@ module Adapters # Default adapter when not using Postgresql Schemas class JDBCPostgresqlAdapter < PostgresqlAdapter - protected - - def create_tenant(tenant) - # There is a bug in activerecord-jdbcpostgresql-adapter (1.2.5) that will cause - # an exception if no options are passed into the create_database call. - Apartment.connection.create_database(environmentify(tenant), { :thisisahack => '' }) + private - rescue *rescuable_exceptions - raise TenantExists, "The tenant #{environmentify(tenant)} already exists." + def multi_tenantify_with_tenant_db_name(config, tenant) + config[:url] = "#{config[:url].gsub(/(\S+)\/.+$/, '\1')}/#{environmentify(tenant)}" end - # Return a new config that is multi-tenanted - # - def multi_tenantify(tenant) - @config.clone.tap do |config| - config[:url] = "#{config[:url].gsub(/(\S+)\/.+$/, '\1')}/#{environmentify(tenant)}" - end + def create_tenant_command(conn, tenant) + conn.create_database(environmentify(tenant), { :thisisahack => '' }) end - private - def rescue_from ActiveRecord::JDBCError end diff --git a/lib/apartment/adapters/mysql2_adapter.rb b/lib/apartment/adapters/mysql2_adapter.rb index 2b7b9396..d1e324ca 100644 --- a/lib/apartment/adapters/mysql2_adapter.rb +++ b/lib/apartment/adapters/mysql2_adapter.rb @@ -21,17 +21,8 @@ def initialize(config) protected - # Connect to new tenant - # Abstract adapter will catch generic ActiveRecord error - # Catch specific adapter errors here - # - # @param {String} tenant Tenant name - # - def connect_to_new(tenant = nil) - super - rescue Mysql2::Error - Apartment::Tenant.reset - raise TenantNotFound, "Cannot find tenant #{environmentify(tenant)}" + def rescue_from + Mysql2::Error end end @@ -49,12 +40,6 @@ def reset Apartment.connection.execute "use `#{default_tenant}`" end - # Set the table_name to always use the default tenant for excluded models - # - def process_excluded_models - Apartment.excluded_models.each{ |model| process_excluded_model(model) } - end - protected # Connect to new tenant @@ -64,9 +49,9 @@ def connect_to_new(tenant) Apartment.connection.execute "use `#{environmentify(tenant)}`" - rescue ActiveRecord::StatementInvalid + rescue ActiveRecord::StatementInvalid => exception Apartment::Tenant.reset - raise TenantNotFound, "Cannot find tenant #{environmentify(tenant)}" + raise_connect_error!(tenant, exception) end def process_excluded_model(model) @@ -77,6 +62,10 @@ def process_excluded_model(model) klass.table_name = "#{default_tenant}.#{table_name}" end end + + def reset_on_connection_exception? + true + end end end end diff --git a/lib/apartment/adapters/postgresql_adapter.rb b/lib/apartment/adapters/postgresql_adapter.rb index e5f9730b..cb4aebeb 100644 --- a/lib/apartment/adapters/postgresql_adapter.rb +++ b/lib/apartment/adapters/postgresql_adapter.rb @@ -15,14 +15,6 @@ module Adapters # Default adapter when not using Postgresql Schemas class PostgresqlAdapter < AbstractAdapter - def drop(tenant) - # Apartment.connection.drop_database note that drop_database will not throw an exception, so manually execute - Apartment.connection.execute(%{DROP DATABASE "#{tenant}"}) - - rescue *rescuable_exceptions - raise TenantNotFound, "The tenant #{tenant} cannot be found" - end - private def rescue_from @@ -39,31 +31,6 @@ def initialize(config) reset end - # Drop the tenant - # - # @param {String} tenant Database (schema) to drop - # - def drop(tenant) - Apartment.connection.execute(%{DROP SCHEMA "#{tenant}" CASCADE}) - - rescue *rescuable_exceptions - raise TenantNotFound, "The schema #{tenant.inspect} cannot be found." - end - - # Reset search path to default search_path - # Set the table_name to always use the default namespace for excluded models - # - def process_excluded_models - Apartment.excluded_models.each do |excluded_model| - excluded_model.constantize.tap do |klass| - # Ensure that if a schema *was* set, we override - table_name = klass.table_name.split('.', 2).last - - klass.table_name = "#{default_tenant}.#{table_name}" - end - end - end - # Reset schema search path to the default schema_search_path # # @return {String} default schema search path @@ -79,6 +46,19 @@ def current protected + def process_excluded_model(excluded_model) + excluded_model.constantize.tap do |klass| + # Ensure that if a schema *was* set, we override + table_name = klass.table_name.split('.', 2).last + + klass.table_name = "#{default_tenant}.#{table_name}" + end + end + + def drop_command(conn, tenant) + conn.execute(%{DROP SCHEMA "#{tenant}" CASCADE}) + end + # Set schema search path to new schema # def connect_to_new(tenant = nil) @@ -92,17 +72,12 @@ def connect_to_new(tenant = nil) raise TenantNotFound, "One of the following schema(s) is invalid: \"#{tenant}\" #{full_search_path}" end - # Create the new schema - # - def create_tenant(tenant) - Apartment.connection.execute(%{CREATE SCHEMA "#{tenant}"}) + private - rescue *rescuable_exceptions - raise TenantExists, "The schema #{tenant} already exists." + def create_tenant_command(conn, tenant) + conn.execute(%{CREATE SCHEMA "#{tenant}"}) end - private - # Generate the final search path to set including persistent_schemas # def full_search_path diff --git a/lib/generators/apartment/install/templates/apartment.rb b/lib/generators/apartment/install/templates/apartment.rb index 5fe07fe8..90bf4abc 100644 --- a/lib/generators/apartment/install/templates/apartment.rb +++ b/lib/generators/apartment/install/templates/apartment.rb @@ -19,10 +19,33 @@ # In order to migrate all of your Tenants you need to provide a list of Tenant names to Apartment. # You can make this dynamic by providing a Proc object to be called on migrations. - # This object should yield an array of strings representing each Tenant name. + # This object should yield either: + # - an array of strings representing each Tenant name. + # - a hash which keys are tenant names, and values custom db config (must contain all key/values required in database.yml) # # config.tenant_names = lambda{ Customer.pluck(:tenant_name) } # config.tenant_names = ['tenant1', 'tenant2'] + # config.tenant_names = { + # 'tenant1' => { + # adapter: 'postgresql', + # host: 'some_server', + # port: 5555, + # database: 'postgres' # this is not the name of the tenant's db + # # but the name of the database to connect to before creating the tenant's db + # # mandatory in postgresql + # }, + # 'tenant2' => { + # adapter: 'postgresql', + # database: 'postgres' # this is not the name of the tenant's db + # # but the name of the database to connect to before creating the tenant's db + # # mandatory in postgresql + # } + # } + # config.tenant_names = lambda do + # Tenant.all.each_with_object({}) do |tenant, hash| + # hash[tenant.name] = tenant.db_configuration + # end + # end # config.tenant_names = lambda { ToDo_Tenant_Or_User_Model.pluck :database } diff --git a/lib/tasks/apartment.rake b/lib/tasks/apartment.rake index 15115553..3c70df94 100644 --- a/lib/tasks/apartment.rake +++ b/lib/tasks/apartment.rake @@ -17,7 +17,6 @@ apartment_namespace = namespace :apartment do desc "Migrate all tenants" task :migrate do warn_if_tenants_empty - tenants.each do |tenant| begin puts("Migrating #{tenant} tenant") diff --git a/spec/adapters/mysql2_adapter_spec.rb b/spec/adapters/mysql2_adapter_spec.rb index 98b884bd..ac3d275f 100644 --- a/spec/adapters/mysql2_adapter_spec.rb +++ b/spec/adapters/mysql2_adapter_spec.rb @@ -44,6 +44,7 @@ def tenant_names before { Apartment.use_schemas = false } it_should_behave_like "a generic apartment adapter" + it_should_behave_like "a generic apartment adapter able to handle custom configuration" it_should_behave_like "a connection based apartment adapter" end end diff --git a/spec/adapters/postgresql_adapter_spec.rb b/spec/adapters/postgresql_adapter_spec.rb index 3ca9dd71..e246fd10 100644 --- a/spec/adapters/postgresql_adapter_spec.rb +++ b/spec/adapters/postgresql_adapter_spec.rb @@ -54,6 +54,7 @@ def tenant_names let(:default_tenant) { subject.switch { ActiveRecord::Base.connection.current_database } } it_should_behave_like "a generic apartment adapter" + it_should_behave_like "a generic apartment adapter able to handle custom configuration" it_should_behave_like "a connection based apartment adapter" end end diff --git a/spec/examples/generic_adapter_custom_configuration_example.rb b/spec/examples/generic_adapter_custom_configuration_example.rb new file mode 100644 index 00000000..c61e2b1d --- /dev/null +++ b/spec/examples/generic_adapter_custom_configuration_example.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +shared_examples_for "a generic apartment adapter able to handle custom configuration" do + + let(:custom_tenant_name) { 'test_tenantwwww' } + let(:db) { |example| example.metadata[:database]} + let(:custom_tenant_names) do + { + custom_tenant_name => get_custom_db_conf + } + end + + before do + Apartment.tenant_names = custom_tenant_names + end + + context "database key taken from specific config" do + + let(:expected_args) { get_custom_db_conf } + + describe "#create" do + it "should establish_connection with the separate connection with expected args" do + expect(Apartment::Adapters::AbstractAdapter::SeparateDbConnectionHandler).to receive(:establish_connection).with(expected_args).and_call_original + + # because we dont have another server to connect to it errors + # what matters is establish_connection receives proper args + expect { subject.create(custom_tenant_name) }.to raise_error(Apartment::TenantExists) + end + end + + describe "#drop" do + it "should establish_connection with the separate connection with expected args" do + expect(Apartment::Adapters::AbstractAdapter::SeparateDbConnectionHandler).to receive(:establish_connection).with(expected_args).and_call_original + + # because we dont have another server to connect to it errors + # what matters is establish_connection receives proper args + expect { subject.drop(custom_tenant_name) }.to raise_error(Apartment::TenantNotFound) + end + end + end + + context "database key from tenant name" do + + let(:expected_args) { + get_custom_db_conf.tap {|args| args.delete(:database) } + } + + describe "#switch!" do + + it "should connect to new db" do + expect(Apartment).to receive(:establish_connection) do |args| + db_name = args.delete(:database) + + expect(args).to eq expected_args + expect(db_name).to match custom_tenant_name + + # we only need to check args, then we short circuit + # in order to avoid the mess due to the `establish_connection` override + raise ActiveRecord::ActiveRecordError + end + + expect { subject.switch!(custom_tenant_name) }.to raise_error(Apartment::TenantNotFound) + end + end + end + + def specific_connection + { + postgresql: { + adapter: 'postgresql', + database: 'override_database', + password: 'override_password', + username: 'overridepostgres' + }, + mysql: { + adapter: 'mysql2', + database: 'override_database', + username: 'root' + }, + sqlite: { + adapter: 'sqlite3', + database: 'override_database' + } + } + end + + def get_custom_db_conf + specific_connection[db.to_sym].with_indifferent_access + end +end diff --git a/spec/integration/apartment_rake_integration_spec.rb b/spec/integration/apartment_rake_integration_spec.rb index bc79bb0c..955f10ce 100644 --- a/spec/integration/apartment_rake_integration_spec.rb +++ b/spec/integration/apartment_rake_integration_spec.rb @@ -33,7 +33,7 @@ let(:x){ 1 + rand(5) } # random number of dbs to create let(:db_names){ x.times.map{ Apartment::Test.next_db } } - let!(:company_count){ Company.count + db_names.length } + let!(:company_count){ db_names.length } before do db_names.collect do |db_name| diff --git a/spec/support/apartment_helpers.rb b/spec/support/apartment_helpers.rb index 19485055..b3cce4ee 100644 --- a/spec/support/apartment_helpers.rb +++ b/spec/support/apartment_helpers.rb @@ -15,6 +15,12 @@ def next_db "db%d" % @x += 1 end + def reset_table_names + Apartment.excluded_models.each do |model| + model.constantize.reset_table_name + end + end + def drop_schema(schema) ActiveRecord::Base.connection.execute("DROP SCHEMA IF EXISTS #{schema} CASCADE") rescue true end @@ -40,4 +46,4 @@ def rollback end end -end \ No newline at end of file +end diff --git a/spec/support/setup.rb b/spec/support/setup.rb index 1139587f..61830d70 100644 --- a/spec/support/setup.rb +++ b/spec/support/setup.rb @@ -22,6 +22,7 @@ def config # before Apartment::Tenant.reload!(config) ActiveRecord::Base.establish_connection config + Apartment::Test.reset_table_names example.run @@ -34,9 +35,8 @@ def config Apartment.connection_class.remove_connection(klass) klass.clear_all_connections! - klass.reset_table_name end - + Apartment::Test.reset_table_names Apartment.reset Apartment::Tenant.reload! end diff --git a/spec/unit/config_spec.rb b/spec/unit/config_spec.rb index 89584d67..772cc2ab 100644 --- a/spec/unit/config_spec.rb +++ b/spec/unit/config_spec.rb @@ -7,6 +7,12 @@ let(:excluded_models){ ["Company"] } let(:seed_data_file_path){ "#{Rails.root}/db/seeds/import.rb" } + def tenant_names_from_array(names) + names.each_with_object({}) do |tenant, hash| + hash[tenant] = Apartment.connection_config + end.with_indifferent_access + end + it "should yield the Apartment object" do Apartment.configure do |config| config.excluded_models = [] @@ -52,36 +58,60 @@ end context "databases" do - it "should return object if it doesnt respond_to call" do - tenant_names = ['users', 'companies'] + let(:users_conf_hash) { { port: 5444 } } + before do Apartment.configure do |config| - config.excluded_models = [] config.tenant_names = tenant_names end - Apartment.tenant_names.should == tenant_names end - it "should invoke the proc if appropriate" do - tenant_names = lambda{ ['users', 'users'] } - tenant_names.should_receive(:call) + context "tenant_names as string array" do + let(:tenant_names) { ['users', 'companies'] } - Apartment.configure do |config| - config.excluded_models = [] - config.tenant_names = tenant_names + it "should return object if it doesnt respond_to call" do + Apartment.tenant_names.should == tenant_names_from_array(tenant_names).keys + end + + it "should set tenants_with_config" do + Apartment.tenants_with_config.should == tenant_names_from_array(tenant_names) end - Apartment.tenant_names end - it "should return the invoked proc if appropriate" do - dbs = lambda{ Company.all } + context "tenant_names as proc returning an array" do + let(:tenant_names) { lambda { ['users', 'companies'] } } - Apartment.configure do |config| - config.excluded_models = [] - config.tenant_names = dbs + it "should return object if it doesnt respond_to call" do + Apartment.tenant_names.should == tenant_names_from_array(tenant_names.call).keys + end + + it "should set tenants_with_config" do + Apartment.tenants_with_config.should == tenant_names_from_array(tenant_names.call) + end + end + + context "tenant_names as Hash" do + let(:tenant_names) { { users: users_conf_hash }.with_indifferent_access } + + it "should return object if it doesnt respond_to call" do + Apartment.tenant_names.should == tenant_names.keys + end + + it "should set tenants_with_config" do + Apartment.tenants_with_config.should == tenant_names end + end + + context "tenant_names as proc returning a Hash" do + let(:tenant_names) { lambda { { users: users_conf_hash }.with_indifferent_access } } - Apartment.tenant_names.should == Company.all + it "should return object if it doesnt respond_to call" do + Apartment.tenant_names.should == tenant_names.call.keys + end + + it "should set tenants_with_config" do + Apartment.tenants_with_config.should == tenant_names.call + end end end