puzzle-apartment 2.12.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +71 -0
- data/.github/ISSUE_TEMPLATE.md +21 -0
- data/.github/workflows/changelog.yml +63 -0
- data/.github/workflows/reviewdog.yml +22 -0
- data/.gitignore +15 -0
- data/.pryrc +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +33 -0
- data/.rubocop_todo.yml +418 -0
- data/.ruby-version +1 -0
- data/.story_branch.yml +5 -0
- data/Appraisals +49 -0
- data/CHANGELOG.md +963 -0
- data/Gemfile +17 -0
- data/Guardfile +11 -0
- data/HISTORY.md +496 -0
- data/README.md +652 -0
- data/Rakefile +157 -0
- data/TODO.md +50 -0
- data/docker-compose.yml +33 -0
- data/gemfiles/rails_6_1.gemfile +17 -0
- data/gemfiles/rails_7_0.gemfile +17 -0
- data/gemfiles/rails_7_1.gemfile +17 -0
- data/gemfiles/rails_master.gemfile +17 -0
- data/lib/apartment/active_record/connection_handling.rb +34 -0
- data/lib/apartment/active_record/internal_metadata.rb +9 -0
- data/lib/apartment/active_record/postgresql_adapter.rb +39 -0
- data/lib/apartment/active_record/schema_migration.rb +13 -0
- data/lib/apartment/adapters/abstract_adapter.rb +275 -0
- data/lib/apartment/adapters/abstract_jdbc_adapter.rb +20 -0
- data/lib/apartment/adapters/jdbc_mysql_adapter.rb +19 -0
- data/lib/apartment/adapters/jdbc_postgresql_adapter.rb +62 -0
- data/lib/apartment/adapters/mysql2_adapter.rb +77 -0
- data/lib/apartment/adapters/postgis_adapter.rb +13 -0
- data/lib/apartment/adapters/postgresql_adapter.rb +284 -0
- data/lib/apartment/adapters/sqlite3_adapter.rb +66 -0
- data/lib/apartment/console.rb +24 -0
- data/lib/apartment/custom_console.rb +42 -0
- data/lib/apartment/deprecation.rb +11 -0
- data/lib/apartment/elevators/domain.rb +23 -0
- data/lib/apartment/elevators/first_subdomain.rb +18 -0
- data/lib/apartment/elevators/generic.rb +33 -0
- data/lib/apartment/elevators/host.rb +35 -0
- data/lib/apartment/elevators/host_hash.rb +26 -0
- data/lib/apartment/elevators/subdomain.rb +66 -0
- data/lib/apartment/log_subscriber.rb +45 -0
- data/lib/apartment/migrator.rb +52 -0
- data/lib/apartment/model.rb +29 -0
- data/lib/apartment/railtie.rb +68 -0
- data/lib/apartment/tasks/enhancements.rb +55 -0
- data/lib/apartment/tasks/task_helper.rb +52 -0
- data/lib/apartment/tenant.rb +63 -0
- data/lib/apartment/version.rb +5 -0
- data/lib/apartment.rb +159 -0
- data/lib/generators/apartment/install/USAGE +5 -0
- data/lib/generators/apartment/install/install_generator.rb +11 -0
- data/lib/generators/apartment/install/templates/apartment.rb +116 -0
- data/lib/tasks/apartment.rake +106 -0
- data/puzzle-apartment.gemspec +59 -0
- metadata +385 -0
data/Rakefile
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'bundler'
|
5
|
+
rescue StandardError
|
6
|
+
'You must `gem install bundler` and `bundle install` to run rake tasks'
|
7
|
+
end
|
8
|
+
Bundler.setup
|
9
|
+
Bundler::GemHelper.install_tasks
|
10
|
+
|
11
|
+
require 'appraisal'
|
12
|
+
|
13
|
+
require 'rspec'
|
14
|
+
require 'rspec/core/rake_task'
|
15
|
+
|
16
|
+
RSpec::Core::RakeTask.new(spec: %w[db:copy_credentials db:test:prepare]) do |spec|
|
17
|
+
spec.pattern = 'spec/**/*_spec.rb'
|
18
|
+
# spec.rspec_opts = '--order rand:47078'
|
19
|
+
end
|
20
|
+
|
21
|
+
namespace :spec do
|
22
|
+
%i[tasks unit adapters integration].each do |type|
|
23
|
+
RSpec::Core::RakeTask.new(type => :spec) do |spec|
|
24
|
+
spec.pattern = "spec/#{type}/**/*_spec.rb"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
task :console do
|
30
|
+
require 'pry'
|
31
|
+
require 'apartment'
|
32
|
+
ARGV.clear
|
33
|
+
Pry.start
|
34
|
+
end
|
35
|
+
|
36
|
+
task default: :spec
|
37
|
+
|
38
|
+
namespace :db do
|
39
|
+
namespace :test do
|
40
|
+
task prepare: %w[postgres:drop_db postgres:build_db mysql:drop_db mysql:build_db]
|
41
|
+
end
|
42
|
+
|
43
|
+
desc "copy sample database credential files over if real files don't exist"
|
44
|
+
task :copy_credentials do
|
45
|
+
require 'fileutils'
|
46
|
+
apartment_db_file = 'spec/config/database.yml'
|
47
|
+
rails_db_file = 'spec/dummy/config/database.yml'
|
48
|
+
|
49
|
+
unless File.exist?(apartment_db_file)
|
50
|
+
FileUtils.copy("#{apartment_db_file}.sample", apartment_db_file, verbose: true)
|
51
|
+
end
|
52
|
+
FileUtils.copy("#{rails_db_file}.sample", rails_db_file, verbose: true) unless File.exist?(rails_db_file)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
namespace :postgres do
|
57
|
+
require 'active_record'
|
58
|
+
require File.join(File.dirname(__FILE__), 'spec', 'support', 'config').to_s
|
59
|
+
|
60
|
+
desc 'Build the PostgreSQL test databases'
|
61
|
+
task :build_db do
|
62
|
+
params = []
|
63
|
+
params << '-E UTF8'
|
64
|
+
params << pg_config['database']
|
65
|
+
params << "-U#{pg_config['username']}"
|
66
|
+
params << "-h#{pg_config['host']}" if pg_config['host']
|
67
|
+
params << "-p#{pg_config['port']}" if pg_config['port']
|
68
|
+
|
69
|
+
begin
|
70
|
+
`createdb #{params.join(' ')}`
|
71
|
+
rescue StandardError
|
72
|
+
'test db already exists'
|
73
|
+
end
|
74
|
+
ActiveRecord::Base.establish_connection pg_config
|
75
|
+
migrate
|
76
|
+
end
|
77
|
+
|
78
|
+
desc 'drop the PostgreSQL test database'
|
79
|
+
task :drop_db do
|
80
|
+
puts "dropping database #{pg_config['database']}"
|
81
|
+
params = []
|
82
|
+
params << pg_config['database']
|
83
|
+
params << "-U#{pg_config['username']}"
|
84
|
+
params << "-h#{pg_config['host']}" if pg_config['host']
|
85
|
+
params << "-p#{pg_config['port']}" if pg_config['port']
|
86
|
+
`dropdb #{params.join(' ')}`
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
namespace :mysql do
|
91
|
+
require 'active_record'
|
92
|
+
require File.join(File.dirname(__FILE__), 'spec', 'support', 'config').to_s
|
93
|
+
|
94
|
+
desc 'Build the MySQL test databases'
|
95
|
+
task :build_db do
|
96
|
+
params = []
|
97
|
+
params << "-h #{my_config['host']}" if my_config['host']
|
98
|
+
params << "-u #{my_config['username']}" if my_config['username']
|
99
|
+
params << "-p#{my_config['password']}" if my_config['password']
|
100
|
+
params << "--port #{my_config['port']}" if my_config['port']
|
101
|
+
begin
|
102
|
+
`mysqladmin #{params.join(' ')} create #{my_config['database']}`
|
103
|
+
rescue StandardError
|
104
|
+
'test db already exists'
|
105
|
+
end
|
106
|
+
ActiveRecord::Base.establish_connection my_config
|
107
|
+
migrate
|
108
|
+
end
|
109
|
+
|
110
|
+
desc 'drop the MySQL test database'
|
111
|
+
task :drop_db do
|
112
|
+
puts "dropping database #{my_config['database']}"
|
113
|
+
params = []
|
114
|
+
params << "-h #{my_config['host']}" if my_config['host']
|
115
|
+
params << "-u #{my_config['username']}" if my_config['username']
|
116
|
+
params << "-p#{my_config['password']}" if my_config['password']
|
117
|
+
params << "--port #{my_config['port']}" if my_config['port']
|
118
|
+
`mysqladmin #{params.join(' ')} drop #{my_config['database']} --force`
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# TODO: clean this up
|
123
|
+
def config
|
124
|
+
Apartment::Test.config['connections']
|
125
|
+
end
|
126
|
+
|
127
|
+
def pg_config
|
128
|
+
config['postgresql']
|
129
|
+
end
|
130
|
+
|
131
|
+
def my_config
|
132
|
+
config['mysql']
|
133
|
+
end
|
134
|
+
|
135
|
+
def activerecord_below_5_2?
|
136
|
+
ActiveRecord.version.release < Gem::Version.new('5.2.0')
|
137
|
+
end
|
138
|
+
|
139
|
+
def activerecord_below_6_0?
|
140
|
+
ActiveRecord.version.release < Gem::Version.new('6.0.0')
|
141
|
+
end
|
142
|
+
|
143
|
+
def activerecord_above_7_0?
|
144
|
+
ActiveRecord.version.release > Gem::Version.new('7.0.0')
|
145
|
+
end
|
146
|
+
|
147
|
+
def migrate
|
148
|
+
if activerecord_below_5_2?
|
149
|
+
ActiveRecord::Migrator.migrate('spec/dummy/db/migrate')
|
150
|
+
elsif activerecord_below_6_0? || activerecord_above_7_0?
|
151
|
+
ActiveRecord::MigrationContext.new('spec/dummy/db/migrate').migrate
|
152
|
+
else
|
153
|
+
# TODO: Figure out if there is any other possibility that can/should be
|
154
|
+
# passed here as the second argument for the migration context
|
155
|
+
ActiveRecord::MigrationContext.new('spec/dummy/db/migrate', ActiveRecord::SchemaMigration).migrate
|
156
|
+
end
|
157
|
+
end
|
data/TODO.md
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
# Apartment TODOs
|
2
|
+
|
3
|
+
### Below is a list of tasks in the approximate order to be completed of for Apartment
|
4
|
+
### Any help along the way is greatly appreciated (on any items, not particularly in order)
|
5
|
+
|
6
|
+
1. Apartment was originally written (and TDD'd) with just Postgresql in mind. Different adapters were added at a later date.
|
7
|
+
As such, the test suite is a bit of a mess. There's no formal structure for fully integration testing all adapters to ensure
|
8
|
+
proper quality and prevent regressions.
|
9
|
+
|
10
|
+
There's also a test order dependency as some tests run assuming a db connection and if that test randomly ran before a previous
|
11
|
+
one that makes the connection, it would fail.
|
12
|
+
|
13
|
+
I'm proposing the first thing to be done is to write up a standard, high livel integration test case that can be applied to all adapters
|
14
|
+
and makes no assumptions about implementation. It should ensure that each adapter conforms to the Apartment Interface and CRUD's properly.
|
15
|
+
It would be nice if a user can 'register' an adapter such that it would automatically be tested (nice to have). Otherwise one could just use
|
16
|
+
a shared behaviour to run through all of this.
|
17
|
+
|
18
|
+
Then, I'd like to see all of the implementation specific tests just in their own test file for each adapter (ie the postgresql schema adapter checks a lot of things with `schema_search_path`)
|
19
|
+
|
20
|
+
This should ensure that going forward nothing breaks, and we should *ideally* be able to randomize the test order
|
21
|
+
|
22
|
+
2. <del>`Apartment::Database` is the wrong abstraction. When dealing with a multi-tenanted system, users shouldn't thing about 'Databases', they should
|
23
|
+
think about Tenants. I proprose that we deprecate the `Apartment::Database` constant in favour of `Apartment::Tenant` for a nicer abstraction. See
|
24
|
+
http://myronmars.to/n/dev-blog/2011/09/deprecating-constants-and-classes-in-ruby for ideas on how to achieve this.</del>
|
25
|
+
|
26
|
+
4. Apartment::Database.process should be deprecated in favour of just passing a block to `switch`
|
27
|
+
5. Apartment::Database.switch should be renamed to switch! to indicate that using it on its own has side effects
|
28
|
+
|
29
|
+
6. Migrations right now can be a bit of a pain. Apartment currently migrates a single tenant completely up to date, then goes onto the next. If one of these
|
30
|
+
migrations fails on a tenant, the previous one does NOT get reverted and leaves you in an awkward state. Ideally we'd want to wrap all of the migrations in
|
31
|
+
a transaction so if one fails, the whole thing reverts. Once we can ensure an all-or-nothing approach to migrations, we can optimize the migration strategy
|
32
|
+
to not even iterate over the tenants if there are no migrations to run on public.
|
33
|
+
|
34
|
+
7. Apartment has be come one of the most popular/robust Multi-tenant gems for Rails, but it still doesn't work for everyone's use case. It's fairly limited in implementation to either schema based (ie postgresql schemas) or connection based. I'd like to abstract out these implementation details such that one could write a pluggable strategy for Apartment and choose it based on a config selection (something like `config.strategy = :schema`). The next implementation I'd like to see is a scoped based approach that uses a `tenant_id` scoping on all records for multi-tenancy. This is probably the most popular multi-tenant approach and is db independent and really the simplest mechanism for a type of multi-tenancy.
|
35
|
+
|
36
|
+
8. Right now excluded tables still live in all tenanted environments. This is basically because it doesn't matter if they're there, we always query from the public.
|
37
|
+
It's a bit of an annoyance though and confuses lots of people. I'd love to see only tenanted tables in the tenants and only excluded tables in the public tenant.
|
38
|
+
This will be hard because Rails uses public to generate schema.rb. One idea is to have an `excluded` schema that holds all the excluded models and the public can
|
39
|
+
maintain everything.
|
40
|
+
|
41
|
+
9. This one is pretty lofty, but I'd also like to abstract out the fact that Apartment uses ActiveRecord. With the new DataMapper coming out soon and other popular
|
42
|
+
DBMS's (ie. mongo, couch etc...), it'd be nice if Apartment could be the de-facto interface for multi-tenancy on these systems.
|
43
|
+
|
44
|
+
|
45
|
+
===================
|
46
|
+
|
47
|
+
Quick TODOs
|
48
|
+
|
49
|
+
2. deprecation.rb rescues everything, we have a hard dependency on ActiveSupport so this is unnecessary
|
50
|
+
3.
|
data/docker-compose.yml
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
version: '2.3'
|
2
|
+
services:
|
3
|
+
postgresql:
|
4
|
+
image: postgres:9.5.12
|
5
|
+
environment:
|
6
|
+
POSTGRES_PASSWORD: ""
|
7
|
+
ports:
|
8
|
+
- "5432:5432"
|
9
|
+
healthcheck:
|
10
|
+
test: pg_isready -U postgres
|
11
|
+
start_period: 10s
|
12
|
+
interval: 10s
|
13
|
+
timeout: 30s
|
14
|
+
retries: 3
|
15
|
+
mysql:
|
16
|
+
image: mysql:5.7
|
17
|
+
environment:
|
18
|
+
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
19
|
+
ports:
|
20
|
+
- "3306:3306"
|
21
|
+
healthcheck:
|
22
|
+
test: mysqladmin -h 127.0.0.1 -uroot ping
|
23
|
+
start_period: 15s
|
24
|
+
interval: 10s
|
25
|
+
timeout: 30s
|
26
|
+
retries: 3
|
27
|
+
healthcheck:
|
28
|
+
image: busybox
|
29
|
+
depends_on:
|
30
|
+
postgresql:
|
31
|
+
condition: service_healthy
|
32
|
+
mysql:
|
33
|
+
condition: service_healthy
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# This file was generated by Appraisal
|
2
|
+
|
3
|
+
source "http://rubygems.org"
|
4
|
+
|
5
|
+
gem "rails", "~> 6.1.0"
|
6
|
+
|
7
|
+
platforms :ruby do
|
8
|
+
gem "sqlite3", "~> 1.4"
|
9
|
+
end
|
10
|
+
|
11
|
+
platforms :jruby do
|
12
|
+
gem "activerecord-jdbc-adapter", "~> 61.0"
|
13
|
+
gem "activerecord-jdbcpostgresql-adapter", "~> 61.0"
|
14
|
+
gem "activerecord-jdbcmysql-adapter", "~> 61.0"
|
15
|
+
end
|
16
|
+
|
17
|
+
gemspec path: "../"
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# This file was generated by Appraisal
|
2
|
+
|
3
|
+
source "http://rubygems.org"
|
4
|
+
|
5
|
+
gem "rails", "~> 7.0.0"
|
6
|
+
|
7
|
+
platforms :ruby do
|
8
|
+
gem "sqlite3", "~> 1.4"
|
9
|
+
end
|
10
|
+
|
11
|
+
platforms :jruby do
|
12
|
+
gem "activerecord-jdbc-adapter", "~> 61.0"
|
13
|
+
gem "activerecord-jdbcpostgresql-adapter", "~> 61.0"
|
14
|
+
gem "activerecord-jdbcmysql-adapter", "~> 61.0"
|
15
|
+
end
|
16
|
+
|
17
|
+
gemspec path: "../"
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# This file was generated by Appraisal
|
2
|
+
|
3
|
+
source "http://rubygems.org"
|
4
|
+
|
5
|
+
gem "rails", "~> 7.1.0"
|
6
|
+
|
7
|
+
platforms :ruby do
|
8
|
+
gem "sqlite3", "~> 1.6"
|
9
|
+
end
|
10
|
+
|
11
|
+
platforms :jruby do
|
12
|
+
gem "activerecord-jdbc-adapter", "~> 61.0"
|
13
|
+
gem "activerecord-jdbcpostgresql-adapter", "~> 61.0"
|
14
|
+
gem "activerecord-jdbcmysql-adapter", "~> 61.0"
|
15
|
+
end
|
16
|
+
|
17
|
+
gemspec path: "../"
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# This file was generated by Appraisal
|
2
|
+
|
3
|
+
source "http://rubygems.org"
|
4
|
+
|
5
|
+
gem "rails", git: "https://github.com/rails/rails.git"
|
6
|
+
|
7
|
+
platforms :ruby do
|
8
|
+
gem "sqlite3", "~> 1.4"
|
9
|
+
end
|
10
|
+
|
11
|
+
platforms :jruby do
|
12
|
+
gem "activerecord-jdbc-adapter", "~> 61.0"
|
13
|
+
gem "activerecord-jdbcpostgresql-adapter", "~> 61.0"
|
14
|
+
gem "activerecord-jdbcmysql-adapter", "~> 61.0"
|
15
|
+
end
|
16
|
+
|
17
|
+
gemspec path: "../"
|
@@ -0,0 +1,34 @@
|
|
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::MAJOR >= 6 && ActiveRecord::VERSION::MAJOR < 7.1
|
9
|
+
def connected_to_with_tenant(role: nil, prevent_writes: false, &blk)
|
10
|
+
current_tenant = Apartment::Tenant.current
|
11
|
+
|
12
|
+
connected_to_without_tenant(role: role, prevent_writes: prevent_writes) do
|
13
|
+
Apartment::Tenant.switch!(current_tenant)
|
14
|
+
yield(blk)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
alias connected_to_without_tenant connected_to
|
19
|
+
alias connected_to connected_to_with_tenant
|
20
|
+
elsif ActiveRecord::VERSION::MAJOR >= 7.1
|
21
|
+
def connected_to_with_tenant(role: nil, shard: nil, prevent_writes: false, &blk)
|
22
|
+
current_tenant = Apartment::Tenant.current
|
23
|
+
|
24
|
+
connected_to_without_tenant(role: role, shard: shard, prevent_writes: prevent_writes) do
|
25
|
+
Apartment::Tenant.switch!(current_tenant)
|
26
|
+
yield(blk)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
alias connected_to_without_tenant connected_to
|
31
|
+
alias connected_to connected_to_with_tenant
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,39 @@
|
|
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
|
+
schema_prefix = "#{Apartment::Tenant.current}."
|
12
|
+
default_tenant_prefix = "#{Apartment::Tenant.default_tenant}."
|
13
|
+
|
14
|
+
# NOTE: Excluded models should always access the sequence from the default
|
15
|
+
# tenant schema
|
16
|
+
if excluded_model?(table)
|
17
|
+
res.sub!(schema_prefix, default_tenant_prefix) if schema_prefix != default_tenant_prefix
|
18
|
+
return res
|
19
|
+
end
|
20
|
+
|
21
|
+
res.delete_prefix!(schema_prefix) if res&.starts_with?(schema_prefix)
|
22
|
+
|
23
|
+
res
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def excluded_model?(table)
|
29
|
+
Apartment.excluded_models.any? { |m| m.constantize.table_name == table }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
require 'active_record/connection_adapters/postgresql_adapter'
|
34
|
+
|
35
|
+
# NOTE: inject this into postgresql adapters
|
36
|
+
class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
37
|
+
include Apartment::PostgreSqlAdapterPatch
|
38
|
+
end
|
39
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
@@ -0,0 +1,275 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apartment
|
4
|
+
module Adapters
|
5
|
+
# Abstract adapter from which all the Apartment DB related adapters will inherit the base logic
|
6
|
+
class AbstractAdapter
|
7
|
+
include ActiveSupport::Callbacks
|
8
|
+
define_callbacks :create, :switch
|
9
|
+
|
10
|
+
attr_writer :default_tenant
|
11
|
+
|
12
|
+
# @constructor
|
13
|
+
# @param {Hash} config Database config
|
14
|
+
#
|
15
|
+
def initialize(config)
|
16
|
+
@config = config
|
17
|
+
end
|
18
|
+
|
19
|
+
# Create a new tenant, import schema, seed if appropriate
|
20
|
+
#
|
21
|
+
# @param {String} tenant Tenant name
|
22
|
+
#
|
23
|
+
def create(tenant)
|
24
|
+
run_callbacks :create do
|
25
|
+
create_tenant(tenant)
|
26
|
+
|
27
|
+
switch(tenant) do
|
28
|
+
import_database_schema
|
29
|
+
|
30
|
+
# Seed data if appropriate
|
31
|
+
seed_data if Apartment.seed_after_create
|
32
|
+
|
33
|
+
yield if block_given?
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Initialize Apartment config options such as excluded_models
|
39
|
+
#
|
40
|
+
def init
|
41
|
+
process_excluded_models
|
42
|
+
end
|
43
|
+
|
44
|
+
# Note alias_method here doesn't work with inheritence apparently ??
|
45
|
+
#
|
46
|
+
def current
|
47
|
+
Apartment.connection.current_database
|
48
|
+
end
|
49
|
+
|
50
|
+
# Return the original public tenant
|
51
|
+
#
|
52
|
+
# @return {String} default tenant name
|
53
|
+
#
|
54
|
+
def default_tenant
|
55
|
+
@default_tenant || Apartment.default_tenant
|
56
|
+
end
|
57
|
+
|
58
|
+
# Drop the tenant
|
59
|
+
#
|
60
|
+
# @param {String} tenant name
|
61
|
+
#
|
62
|
+
def drop(tenant)
|
63
|
+
with_neutral_connection(tenant) do |conn|
|
64
|
+
drop_command(conn, tenant)
|
65
|
+
end
|
66
|
+
rescue *rescuable_exceptions => e
|
67
|
+
raise_drop_tenant_error!(tenant, e)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Switch to a new tenant
|
71
|
+
#
|
72
|
+
# @param {String} tenant name
|
73
|
+
#
|
74
|
+
def switch!(tenant = nil)
|
75
|
+
run_callbacks :switch do
|
76
|
+
connect_to_new(tenant).tap do
|
77
|
+
Apartment.connection.clear_query_cache
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Connect to tenant, do your biz, switch back to previous tenant
|
83
|
+
#
|
84
|
+
# @param {String?} tenant to connect to
|
85
|
+
#
|
86
|
+
def switch(tenant = nil)
|
87
|
+
previous_tenant = current
|
88
|
+
switch!(tenant)
|
89
|
+
yield
|
90
|
+
ensure
|
91
|
+
begin
|
92
|
+
switch!(previous_tenant)
|
93
|
+
rescue StandardError => _e
|
94
|
+
reset
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Iterate over all tenants, switch to tenant and yield tenant name
|
99
|
+
#
|
100
|
+
def each(tenants = Apartment.tenant_names)
|
101
|
+
tenants.each do |tenant|
|
102
|
+
switch(tenant) { yield tenant }
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Establish a new connection for each specific excluded model
|
107
|
+
#
|
108
|
+
def process_excluded_models
|
109
|
+
# All other models will shared a connection (at Apartment.connection_class)
|
110
|
+
# and we can modify at will
|
111
|
+
Apartment.excluded_models.each do |excluded_model|
|
112
|
+
process_excluded_model(excluded_model)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Reset the tenant connection to the default
|
117
|
+
#
|
118
|
+
def reset
|
119
|
+
Apartment.establish_connection @config
|
120
|
+
end
|
121
|
+
|
122
|
+
# Load the rails seed file into the db
|
123
|
+
#
|
124
|
+
def seed_data
|
125
|
+
# Don't log the output of seeding the db
|
126
|
+
silence_warnings { load_or_raise(Apartment.seed_data_file) } if Apartment.seed_data_file
|
127
|
+
end
|
128
|
+
alias seed seed_data
|
129
|
+
|
130
|
+
# Prepend the environment if configured and the environment isn't already there
|
131
|
+
#
|
132
|
+
# @param {String} tenant Database name
|
133
|
+
# @return {String} tenant name with Rails environment *optionally* prepended
|
134
|
+
#
|
135
|
+
def environmentify(tenant)
|
136
|
+
return tenant if tenant.nil? || tenant.include?(Rails.env)
|
137
|
+
|
138
|
+
if Apartment.prepend_environment
|
139
|
+
"#{Rails.env}_#{tenant}"
|
140
|
+
elsif Apartment.append_environment
|
141
|
+
"#{tenant}_#{Rails.env}"
|
142
|
+
else
|
143
|
+
tenant
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
protected
|
148
|
+
|
149
|
+
def process_excluded_model(excluded_model)
|
150
|
+
excluded_model.constantize.establish_connection @config
|
151
|
+
end
|
152
|
+
|
153
|
+
def drop_command(conn, tenant)
|
154
|
+
# connection.drop_database note that drop_database will not throw an exception, so manually execute
|
155
|
+
conn.execute("DROP DATABASE #{conn.quote_table_name(environmentify(tenant))}")
|
156
|
+
end
|
157
|
+
|
158
|
+
# Create the tenant
|
159
|
+
#
|
160
|
+
# @param {String} tenant Database name
|
161
|
+
#
|
162
|
+
def create_tenant(tenant)
|
163
|
+
with_neutral_connection(tenant) do |conn|
|
164
|
+
create_tenant_command(conn, tenant)
|
165
|
+
end
|
166
|
+
rescue *rescuable_exceptions => e
|
167
|
+
raise_create_tenant_error!(tenant, e)
|
168
|
+
end
|
169
|
+
|
170
|
+
def create_tenant_command(conn, tenant)
|
171
|
+
conn.create_database(environmentify(tenant), @config)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Connect to new tenant
|
175
|
+
#
|
176
|
+
# @param {String} tenant Database name
|
177
|
+
#
|
178
|
+
def connect_to_new(tenant)
|
179
|
+
return reset if tenant.nil?
|
180
|
+
|
181
|
+
query_cache_enabled = ActiveRecord::Base.connection.query_cache_enabled
|
182
|
+
|
183
|
+
Apartment.establish_connection multi_tenantify(tenant)
|
184
|
+
Apartment.connection.active? # call active? to manually check if this connection is valid
|
185
|
+
|
186
|
+
Apartment.connection.enable_query_cache! if query_cache_enabled
|
187
|
+
rescue *rescuable_exceptions => e
|
188
|
+
Apartment::Tenant.reset if reset_on_connection_exception?
|
189
|
+
raise_connect_error!(tenant, e)
|
190
|
+
end
|
191
|
+
|
192
|
+
# Import the database schema
|
193
|
+
#
|
194
|
+
def import_database_schema
|
195
|
+
ActiveRecord::Schema.verbose = false # do not log schema load output.
|
196
|
+
|
197
|
+
load_or_raise(Apartment.database_schema_file) if Apartment.database_schema_file
|
198
|
+
end
|
199
|
+
|
200
|
+
# Return a new config that is multi-tenanted
|
201
|
+
# @param {String} tenant: Database name
|
202
|
+
# @param {Boolean} with_database: if true, use the actual tenant's db name
|
203
|
+
# if false, use the default db name from the db
|
204
|
+
# rubocop:disable Style/OptionalBooleanParameter
|
205
|
+
def multi_tenantify(tenant, with_database = true)
|
206
|
+
db_connection_config(tenant).tap do |config|
|
207
|
+
multi_tenantify_with_tenant_db_name(config, tenant) if with_database
|
208
|
+
end
|
209
|
+
end
|
210
|
+
# rubocop:enable Style/OptionalBooleanParameter
|
211
|
+
|
212
|
+
def multi_tenantify_with_tenant_db_name(config, tenant)
|
213
|
+
config[:database] = environmentify(tenant)
|
214
|
+
end
|
215
|
+
|
216
|
+
# Load a file or raise error if it doesn't exists
|
217
|
+
#
|
218
|
+
def load_or_raise(file)
|
219
|
+
raise FileNotFound, "#{file} doesn't exist yet" unless File.exist?(file)
|
220
|
+
|
221
|
+
load(file)
|
222
|
+
end
|
223
|
+
# Backward compatibility
|
224
|
+
alias load_or_abort load_or_raise
|
225
|
+
|
226
|
+
# Exceptions to rescue from on db operations
|
227
|
+
#
|
228
|
+
def rescuable_exceptions
|
229
|
+
[ActiveRecord::ActiveRecordError] + Array(rescue_from)
|
230
|
+
end
|
231
|
+
|
232
|
+
# Extra exceptions to rescue from
|
233
|
+
#
|
234
|
+
def rescue_from
|
235
|
+
[]
|
236
|
+
end
|
237
|
+
|
238
|
+
def db_connection_config(tenant)
|
239
|
+
Apartment.db_config_for(tenant).dup
|
240
|
+
end
|
241
|
+
|
242
|
+
def with_neutral_connection(tenant, &_block)
|
243
|
+
if Apartment.with_multi_server_setup
|
244
|
+
# neutral connection is necessary whenever you need to create/remove a database from a server.
|
245
|
+
# example: when you use postgresql, you need to connect to the default postgresql database before you create
|
246
|
+
# your own.
|
247
|
+
SeparateDbConnectionHandler.establish_connection(multi_tenantify(tenant, false))
|
248
|
+
yield(SeparateDbConnectionHandler.connection)
|
249
|
+
SeparateDbConnectionHandler.connection.close
|
250
|
+
else
|
251
|
+
yield(Apartment.connection)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
def reset_on_connection_exception?
|
256
|
+
false
|
257
|
+
end
|
258
|
+
|
259
|
+
def raise_drop_tenant_error!(tenant, exception)
|
260
|
+
raise TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{exception.message}"
|
261
|
+
end
|
262
|
+
|
263
|
+
def raise_create_tenant_error!(tenant, exception)
|
264
|
+
raise TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{exception.message}"
|
265
|
+
end
|
266
|
+
|
267
|
+
def raise_connect_error!(tenant, exception)
|
268
|
+
raise TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{exception.message}"
|
269
|
+
end
|
270
|
+
|
271
|
+
class SeparateDbConnectionHandler < ::ActiveRecord::Base
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|