Skip to content

Commit

Permalink
configurable connection options for tenants
Browse files Browse the repository at this point in the history
add config specs

fix after @mikecmpbll review

bug fix after clean up

extend flexible options to other adapters

update documentation

removed wrong method override, fix #270

add specs

provide details in exceptions fix #269

remove spec

naming and doc improvements

fix class name after refactoring

move db overrides to spec file

ruby is not mL

add rescue_from to rescued errors: makes sense, people could try to interact with and database

exception fixes

removed adbc expectations as I can't test them locally

every single tenant must specify all database connection options

remove misleading || statement

remove deprecated specs

reset table names prior to spec runs

DRY code

fix typo

reintroduce conditional reset

bug fix

remove if statements, always create tenant config hash

refactoring

remote specs test

fix edge case

rescue only ActiveRecord::StatementInvalid

documentation edition

changed exception method names
  • Loading branch information
apneadiving committed Nov 25, 2015
1 parent e9aed3a commit abdffbf
Show file tree
Hide file tree
Showing 17 changed files with 326 additions and 138 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 22 additions & 2 deletions lib/apartment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
84 changes: 69 additions & 15 deletions lib/apartment/adapters/abstract_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
13 changes: 4 additions & 9 deletions lib/apartment/adapters/abstract_jdbc_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
end
15 changes: 2 additions & 13 deletions lib/apartment/adapters/jdbc_mysql_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 5 additions & 16 deletions lib/apartment/adapters/jdbc_postgresql_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 8 additions & 19 deletions lib/apartment/adapters/mysql2_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Loading

2 comments on commit abdffbf

@masterkain
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite get this.

connect_to_new is defined in both the abstract adapter and postgres adapter, but with different implementations, the postgres always uses schema anyways, but the goal here seems to being able to use different databases.

I cannot get this to work in my project, use_schemas is false.

@bradrobertson
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@masterkain postgres does NOT always use schemas, it has both options. The goal is to allow different physical servers to be switched against.

Note that this hasn't been released yet. I believe there's another PR that needs some cleaning up to deal with threading issues.

If you want to play with this you'll have to reference github directly.

Also note that I didn't write this so open to hearing feedback on what you find and, if it needs any cleanup, happy to accept PRs

Please sign in to comment.