activerecord-tenanted 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +46 -21
- data/Rakefile +5 -6
- data/lib/active_record/tenanted/base.rb +63 -0
- data/lib/active_record/tenanted/cable_connection.rb +50 -0
- data/lib/active_record/tenanted/connection_adapter.rb +22 -0
- data/lib/active_record/tenanted/console.rb +29 -0
- data/lib/active_record/tenanted/database_configurations.rb +166 -0
- data/lib/active_record/tenanted/database_tasks.rb +118 -0
- data/lib/active_record/tenanted/global_id.rb +36 -0
- data/lib/active_record/tenanted/job.rb +37 -0
- data/lib/active_record/tenanted/mailer.rb +15 -0
- data/lib/active_record/tenanted/mutex.rb +66 -0
- data/lib/active_record/tenanted/patches.rb +50 -0
- data/lib/active_record/tenanted/railtie.rb +201 -0
- data/lib/active_record/tenanted/relation.rb +22 -0
- data/lib/active_record/tenanted/storage.rb +49 -0
- data/lib/active_record/tenanted/subtenant.rb +36 -0
- data/lib/active_record/tenanted/tenant.rb +257 -0
- data/lib/active_record/tenanted/tenant_selector.rb +57 -0
- data/lib/active_record/tenanted/testing.rb +121 -0
- data/lib/active_record/tenanted/untenanted_connection_pool.rb +48 -0
- data/lib/{activerecord → active_record}/tenanted/version.rb +2 -2
- data/lib/active_record/tenanted.rb +47 -0
- data/lib/activerecord-tenanted.rb +3 -0
- data/lib/tasks/active_record/tenanted_tasks.rake +78 -0
- metadata +93 -11
- data/CHANGELOG.md +0 -5
- data/CODE_OF_CONDUCT.md +0 -132
- data/LICENSE.txt +0 -21
- data/lib/activerecord/tenanted.rb +0 -10
- data/sig/activerecord/tenanted.rbs +0 -6
@@ -0,0 +1,257 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
# instance methods common to both Tenant and Subtenant
|
6
|
+
module TenantCommon # :nodoc:
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
prepended do
|
10
|
+
attr_reader :tenant
|
11
|
+
|
12
|
+
before_save :ensure_tenant_context_safety
|
13
|
+
end
|
14
|
+
|
15
|
+
def cache_key
|
16
|
+
tenant ? "#{super}?tenant=#{tenant}" : super
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_global_id(options = {})
|
20
|
+
super(options.merge(tenant: tenant))
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_signed_global_id(options = {})
|
24
|
+
super(options.merge(tenant: tenant))
|
25
|
+
end
|
26
|
+
|
27
|
+
def association(name)
|
28
|
+
super.tap do |assoc|
|
29
|
+
if assoc.reflection.polymorphic? || assoc.reflection.klass.tenanted?
|
30
|
+
ensure_tenant_context_safety
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
alias to_gid to_global_id
|
36
|
+
alias to_sgid to_signed_global_id
|
37
|
+
|
38
|
+
private
|
39
|
+
# I would prefer to do this in an `after_initialize` callback, but some associations are
|
40
|
+
# created before those callbacks are invoked (for example, a `belongs_to` association) and
|
41
|
+
# we need to be able to ensure tenant context safety on all associations.
|
42
|
+
def init_internals
|
43
|
+
@tenant = self.class.current_tenant
|
44
|
+
super
|
45
|
+
end
|
46
|
+
|
47
|
+
def ensure_tenant_context_safety
|
48
|
+
self_tenant = self.tenant
|
49
|
+
current_tenant = self.class.current_tenant
|
50
|
+
|
51
|
+
if current_tenant.nil?
|
52
|
+
raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{self.class})"
|
53
|
+
elsif self_tenant != current_tenant
|
54
|
+
raise WrongTenantError,
|
55
|
+
"#{self.class} model belongs to tenant #{self_tenant.inspect}, " \
|
56
|
+
"but current tenant is #{current_tenant.inspect}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
module Tenant
|
62
|
+
extend ActiveSupport::Concern
|
63
|
+
|
64
|
+
# This is a sentinel value used to indicate that the class is not currently tenanted.
|
65
|
+
#
|
66
|
+
# It's the default value returned by `current_shard` when the class is not tenanted. The
|
67
|
+
# `current_tenant` method's job is to recognizes that sentinel value and return `nil`, because
|
68
|
+
# Active Record itself does not recognize `nil` as a valid shard value.
|
69
|
+
UNTENANTED_SENTINEL = Class.new do # :nodoc:
|
70
|
+
def inspect
|
71
|
+
"ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL"
|
72
|
+
end
|
73
|
+
|
74
|
+
def to_s
|
75
|
+
"(untenanted)"
|
76
|
+
end
|
77
|
+
end.new.freeze
|
78
|
+
|
79
|
+
CONNECTION_POOL_CREATION_LOCK = Thread::Mutex.new # :nodoc:
|
80
|
+
|
81
|
+
class_methods do
|
82
|
+
def tenanted?
|
83
|
+
true
|
84
|
+
end
|
85
|
+
|
86
|
+
def current_tenant
|
87
|
+
shard = current_shard
|
88
|
+
shard != UNTENANTED_SENTINEL ? shard : nil
|
89
|
+
end
|
90
|
+
|
91
|
+
def current_tenant=(tenant_name)
|
92
|
+
tenant_name = tenant_name.to_s unless tenant_name == UNTENANTED_SENTINEL
|
93
|
+
|
94
|
+
connection_class_for_self.connecting_to(shard: tenant_name, role: ActiveRecord.writing_role)
|
95
|
+
end
|
96
|
+
|
97
|
+
def tenant_exist?(tenant_name)
|
98
|
+
# this will have to be an adapter-specific implementation if we support other than sqlite
|
99
|
+
database_path = tenanted_root_config.database_path_for(tenant_name)
|
100
|
+
|
101
|
+
File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
|
102
|
+
end
|
103
|
+
|
104
|
+
def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)
|
105
|
+
tenant_name = tenant_name.to_s unless tenant_name == UNTENANTED_SENTINEL
|
106
|
+
|
107
|
+
if tenant_name == current_tenant
|
108
|
+
yield
|
109
|
+
else
|
110
|
+
connection_class_for_self.connected_to(shard: tenant_name, role: ActiveRecord.writing_role) do
|
111
|
+
prohibit_shard_swapping(prohibit_shard_swapping) do
|
112
|
+
log_tenant_tag(tenant_name, &block)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def create_tenant(tenant_name, if_not_exists: false, &block)
|
119
|
+
created_db = false
|
120
|
+
database_path = tenanted_root_config.database_path_for(tenant_name)
|
121
|
+
|
122
|
+
ActiveRecord::Tenanted::Mutex::Ready.lock(database_path) do
|
123
|
+
unless File.exist?(database_path)
|
124
|
+
# NOTE: This is obviously a sqlite-specific implementation.
|
125
|
+
# TODO: Add a `create_database` method upstream in the sqlite3 adapter, and call it.
|
126
|
+
# Then this would delegate to the adapter and become adapter-agnostic.
|
127
|
+
FileUtils.touch(database_path)
|
128
|
+
|
129
|
+
with_tenant(tenant_name) do
|
130
|
+
connection_pool(schema_version_check: false)
|
131
|
+
ActiveRecord::Tenanted::DatabaseTasks.migrate_tenant(tenant_name)
|
132
|
+
end
|
133
|
+
|
134
|
+
created_db = true
|
135
|
+
end
|
136
|
+
rescue
|
137
|
+
FileUtils.rm_f(database_path)
|
138
|
+
raise
|
139
|
+
end
|
140
|
+
|
141
|
+
raise TenantExistsError unless created_db || if_not_exists
|
142
|
+
|
143
|
+
with_tenant(tenant_name) do
|
144
|
+
yield if block_given?
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def destroy_tenant(tenant_name)
|
149
|
+
ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database"
|
150
|
+
|
151
|
+
with_tenant(tenant_name, prohibit_shard_swapping: false) do
|
152
|
+
if retrieve_connection_pool(strict: false)
|
153
|
+
remove_connection
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# NOTE: This is obviously a sqlite-specific implementation.
|
158
|
+
# TODO: Create a `drop_database` method upstream in the sqlite3 adapter, and call it.
|
159
|
+
# Then this would delegate to the adapter and become adapter-agnostic.
|
160
|
+
FileUtils.rm_f(tenanted_root_config.database_path_for(tenant_name))
|
161
|
+
end
|
162
|
+
|
163
|
+
def tenants
|
164
|
+
# DatabaseConfigurations::RootConfig#tenants returns all tenants whose database files
|
165
|
+
# exist, but some of those may be getting initially migrated, so we perform an additional
|
166
|
+
# filter on readiness with `tenant_exist?`.
|
167
|
+
tenanted_root_config.tenants.select { |t| tenant_exist?(t) }
|
168
|
+
end
|
169
|
+
|
170
|
+
def with_each_tenant(**options, &block)
|
171
|
+
tenants.each { |tenant| with_tenant(tenant, **options) { yield tenant } }
|
172
|
+
end
|
173
|
+
|
174
|
+
# This method is really only intended to be used for testing.
|
175
|
+
def without_tenant(&block) # :nodoc:
|
176
|
+
with_tenant(ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL, prohibit_shard_swapping: false, &block)
|
177
|
+
end
|
178
|
+
|
179
|
+
def connection_pool(schema_version_check: true) # :nodoc:
|
180
|
+
if current_tenant
|
181
|
+
pool = retrieve_connection_pool(strict: false)
|
182
|
+
|
183
|
+
if pool.nil?
|
184
|
+
CONNECTION_POOL_CREATION_LOCK.synchronize do
|
185
|
+
# re-check now that we have the lock
|
186
|
+
pool = retrieve_connection_pool(strict: false)
|
187
|
+
|
188
|
+
if pool.nil?
|
189
|
+
_create_tenanted_pool(schema_version_check: schema_version_check)
|
190
|
+
pool = retrieve_connection_pool(strict: true)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
pool
|
196
|
+
else
|
197
|
+
Tenanted::UntenantedConnectionPool.new(tenanted_root_config, self)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def tenanted_root_config # :nodoc:
|
202
|
+
ActiveRecord::Base.configurations.resolve(tenanted_config_name.to_sym)
|
203
|
+
end
|
204
|
+
|
205
|
+
def tenanted_config_name # :nodoc:
|
206
|
+
@tenanted_config_name ||= (superclass.respond_to?(:tenanted_config_name) ? superclass.tenanted_config_name : nil)
|
207
|
+
end
|
208
|
+
|
209
|
+
def _create_tenanted_pool(schema_version_check: true) # :nodoc:
|
210
|
+
# ensure all classes use the same connection pool
|
211
|
+
return superclass._create_tenanted_pool unless connection_class?
|
212
|
+
|
213
|
+
tenant = current_tenant
|
214
|
+
unless File.exist?(tenanted_root_config.database_path_for(tenant))
|
215
|
+
raise TenantDoesNotExistError, "The database file for tenant #{tenant.inspect} does not exist."
|
216
|
+
end
|
217
|
+
|
218
|
+
config = tenanted_root_config.new_tenant_config(tenant)
|
219
|
+
pool = establish_connection(config)
|
220
|
+
|
221
|
+
if schema_version_check
|
222
|
+
pending_migrations = pool.migration_context.open.pending_migrations
|
223
|
+
raise ActiveRecord::PendingMigrationError.new(pending_migrations: pending_migrations) if pending_migrations.any?
|
224
|
+
end
|
225
|
+
|
226
|
+
pool
|
227
|
+
end
|
228
|
+
|
229
|
+
private
|
230
|
+
def retrieve_connection_pool(strict:)
|
231
|
+
connection_handler.retrieve_connection_pool(connection_specification_name,
|
232
|
+
role: current_role,
|
233
|
+
shard: current_tenant,
|
234
|
+
strict: strict)
|
235
|
+
end
|
236
|
+
|
237
|
+
def log_tenant_tag(tenant_name, &block)
|
238
|
+
if Rails.application.config.active_record_tenanted.log_tenant_tag
|
239
|
+
Rails.logger.tagged("tenant=#{tenant_name}", &block)
|
240
|
+
else
|
241
|
+
yield
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
prepended do
|
247
|
+
self.default_shard = ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
|
248
|
+
|
249
|
+
prepend TenantCommon
|
250
|
+
end
|
251
|
+
|
252
|
+
def tenanted?
|
253
|
+
true
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack/contrib"
|
4
|
+
|
5
|
+
module ActiveRecord
|
6
|
+
module Tenanted
|
7
|
+
#
|
8
|
+
# If config.active_record_tenanted.connection_class is set, this middleware will be loaded
|
9
|
+
# automatically, and will use config.active_record_tenanted.tenant_resolver to determine the
|
10
|
+
# appropriate tenant for the request.
|
11
|
+
#
|
12
|
+
# If no tenant is resolved, the request will be executed without wrapping it in a tenanted
|
13
|
+
# context. Application code will be free to set the tenant as needed.
|
14
|
+
#
|
15
|
+
# If a tenant is resolved and the tenant exists, the application will be locked to that
|
16
|
+
# tenant's database for the duration of the request.
|
17
|
+
#
|
18
|
+
# If a tenant is resolved, but the tenant does not exist, a 404 response will be returned.
|
19
|
+
#
|
20
|
+
class TenantSelector
|
21
|
+
attr_reader :app
|
22
|
+
|
23
|
+
def initialize(app)
|
24
|
+
@app = app
|
25
|
+
end
|
26
|
+
|
27
|
+
def call(env)
|
28
|
+
request = ActionDispatch::Request.new(env)
|
29
|
+
tenant_name = tenant_resolver.call(request)
|
30
|
+
|
31
|
+
if tenant_name.blank?
|
32
|
+
# run the request without wrapping it in a tenanted context
|
33
|
+
@app.call(env)
|
34
|
+
elsif tenanted_class.tenant_exist?(tenant_name)
|
35
|
+
tenanted_class.with_tenant(tenant_name) { @app.call(env) }
|
36
|
+
else
|
37
|
+
Rails.logger.info("ActiveRecord::Tenanted::TenantSelector: Tenant not found: #{tenant_name.inspect}")
|
38
|
+
Rack::NotFound.new(Rails.root.join("public/404.html")).call(env)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def tenanted_class
|
43
|
+
# Note: we'll probably want to cache this when we look at performance, but don't cache it
|
44
|
+
# when class reloading is enabled.
|
45
|
+
tenanted_class_name.constantize
|
46
|
+
end
|
47
|
+
|
48
|
+
def tenanted_class_name
|
49
|
+
@tenanted_class_name ||= Rails.application.config.active_record_tenanted.connection_class
|
50
|
+
end
|
51
|
+
|
52
|
+
def tenant_resolver
|
53
|
+
@tenanted_resolver ||= Rails.application.config.active_record_tenanted.tenant_resolver
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
module Testing
|
6
|
+
module ActiveSupportTestCase
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
prepended do
|
10
|
+
if klass = ActiveRecord::Tenanted.connection_class
|
11
|
+
klass.current_tenant = Rails.application.config.active_record_tenanted.default_tenant
|
12
|
+
tenant_genesis(klass)
|
13
|
+
|
14
|
+
parallelize_setup do |worker|
|
15
|
+
# free up the connection pool for the tenant name
|
16
|
+
klass.destroy_tenant(klass.current_tenant)
|
17
|
+
|
18
|
+
# destroy and create tenant databases for this worker's unique id
|
19
|
+
klass.tenanted_root_config.test_worker_id = worker
|
20
|
+
tenant_genesis(klass)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class_methods do
|
26
|
+
private
|
27
|
+
# Destroy any existing tenants from the last test run, and create a fresh tenant
|
28
|
+
# database for the current tenant.
|
29
|
+
#
|
30
|
+
# Yes, this is a Star Trek reference.
|
31
|
+
def tenant_genesis(klass)
|
32
|
+
klass.tenants.each { |tenant| klass.destroy_tenant(tenant) }
|
33
|
+
klass.create_tenant(klass.current_tenant)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
module ActionDispatchIntegrationTest
|
39
|
+
extend ActiveSupport::Concern
|
40
|
+
|
41
|
+
prepended do
|
42
|
+
setup do
|
43
|
+
if klass = ActiveRecord::Tenanted.connection_class
|
44
|
+
integration_session.host = "#{klass.current_tenant}.example.com"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
module ActionDispatchIntegrationSession
|
51
|
+
def process(...)
|
52
|
+
if klass = ActiveRecord::Tenanted.connection_class
|
53
|
+
klass.without_tenant { super }
|
54
|
+
else
|
55
|
+
super
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
module ActionDispatchSystemTestCase
|
61
|
+
extend ActiveSupport::Concern
|
62
|
+
|
63
|
+
prepended do
|
64
|
+
setup do
|
65
|
+
if klass = ActiveRecord::Tenanted.connection_class
|
66
|
+
self.default_url_options = { host: "#{klass.current_tenant}.example.localhost" }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
module ActiveRecordFixtures
|
73
|
+
def transactional_tests_for_pool?(pool)
|
74
|
+
config = pool.db_config
|
75
|
+
|
76
|
+
# Prevent the tenanted RootConfig from creating transactional fixtures on an unnecessary
|
77
|
+
# database, which would result in sporadic locking errors.
|
78
|
+
is_root_config = config.instance_of?(Tenanted::DatabaseConfigurations::RootConfig)
|
79
|
+
|
80
|
+
# Any tenanted database that isn't the default test fixture database should not be wrapped
|
81
|
+
# in a transaction, for a couple of reasons:
|
82
|
+
#
|
83
|
+
# 1. we migrate the database using a temporary pool, which will wrap the schema load in a
|
84
|
+
# transaction that will not be visible to any connection used by the code under test to
|
85
|
+
# insert data.
|
86
|
+
# 2. having an open transaction will prevent the test from being able to destroy the tenant.
|
87
|
+
is_non_default_tenant = (
|
88
|
+
config.instance_of?(Tenanted::DatabaseConfigurations::TenantConfig) &&
|
89
|
+
config.tenant != Rails.application.config.active_record_tenanted.default_tenant.to_s
|
90
|
+
)
|
91
|
+
|
92
|
+
return false if is_root_config || is_non_default_tenant
|
93
|
+
|
94
|
+
super
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
module ActiveJobTestCase
|
99
|
+
def perform_enqueued_jobs(...)
|
100
|
+
if klass = ActiveRecord::Tenanted.connection_class
|
101
|
+
klass.without_tenant { super }
|
102
|
+
else
|
103
|
+
super
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
module ActionCableTestCase
|
109
|
+
def connect(path = ActionCable.server.config.mount_path, **request_params)
|
110
|
+
if (klass = ActiveRecord::Tenanted.connection_class) && klass.current_tenant
|
111
|
+
env = request_params.fetch(:env, {})
|
112
|
+
env["HTTP_HOST"] ||= "#{klass.current_tenant}.example.com"
|
113
|
+
request_params[:env] = env
|
114
|
+
end
|
115
|
+
|
116
|
+
super
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
# In an untenanted context, instances of this class are returned by `Tenant.connection_pool`.
|
6
|
+
#
|
7
|
+
# Many places in Rails assume that `.connection_pool` can be called and will return an object,
|
8
|
+
# and so we can't just raise an exception if it's called while untenanted.
|
9
|
+
#
|
10
|
+
# Instead, this class exists to provide a minimal set of features that don't need a database
|
11
|
+
# connection, and that will raise if a connection is attempted.
|
12
|
+
class UntenantedConnectionPool < ActiveRecord::ConnectionAdapters::NullPool # :nodoc:
|
13
|
+
attr_reader :db_config
|
14
|
+
|
15
|
+
def initialize(db_config, model)
|
16
|
+
super()
|
17
|
+
|
18
|
+
@db_config = db_config
|
19
|
+
@model = model
|
20
|
+
end
|
21
|
+
|
22
|
+
def schema_reflection
|
23
|
+
schema_cache_path = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(db_config)
|
24
|
+
ActiveRecord::ConnectionAdapters::SchemaReflection.new(schema_cache_path)
|
25
|
+
end
|
26
|
+
|
27
|
+
def schema_cache
|
28
|
+
ActiveRecord::ConnectionAdapters::BoundSchemaReflection.new(schema_reflection, self)
|
29
|
+
end
|
30
|
+
|
31
|
+
def lease_connection(...)
|
32
|
+
raise Tenanted::NoTenantError, "Cannot connect to a tenanted database while untenanted (#{@model})."
|
33
|
+
end
|
34
|
+
|
35
|
+
def checkout(...)
|
36
|
+
raise Tenanted::NoTenantError, "Cannot connect to a tenanted database while untenanted (#{@model})."
|
37
|
+
end
|
38
|
+
|
39
|
+
def with_connection(...)
|
40
|
+
raise Tenanted::NoTenantError, "Cannot connect to a tenanted database while untenanted (#{@model})."
|
41
|
+
end
|
42
|
+
|
43
|
+
def new_connection(...)
|
44
|
+
raise Tenanted::NoTenantError, "Cannot connect to a tenanted database while untenanted (#{@model})."
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
|
5
|
+
require "zeitwerk"
|
6
|
+
loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord)
|
7
|
+
loader.setup
|
8
|
+
|
9
|
+
module ActiveRecord
|
10
|
+
module Tenanted
|
11
|
+
# Base exception class for the library.
|
12
|
+
class Error < StandardError; end
|
13
|
+
|
14
|
+
# Raised when database access is attempted without a current tenant having been set.
|
15
|
+
class NoTenantError < Error; end
|
16
|
+
|
17
|
+
# Raised when database access is attempted on a record whose tenant does not match the current tenant.
|
18
|
+
class WrongTenantError < Error; end
|
19
|
+
|
20
|
+
# Raised when attempting to locate a GlobalID without a tenant.
|
21
|
+
class MissingTenantError < Error; end
|
22
|
+
|
23
|
+
# Raised when attempting to create a tenant that already exists.
|
24
|
+
class TenantExistsError < Error; end
|
25
|
+
|
26
|
+
# Raised when attempting to create a tenant with illegal characters in it.
|
27
|
+
class BadTenantNameError < Error; end
|
28
|
+
|
29
|
+
# Raised when the application's tenant configuration is invalid.
|
30
|
+
class TenantConfigurationError < Error; end
|
31
|
+
|
32
|
+
# Raised when implicit creation is disabled and a tenant is referenced that does not exist
|
33
|
+
class TenantDoesNotExistError < Error; end
|
34
|
+
|
35
|
+
# Raised when the Rails integration is being invoked but has not been configured.
|
36
|
+
class IntegrationNotConfiguredError < Error; end
|
37
|
+
|
38
|
+
def self.connection_class
|
39
|
+
# TODO: cache this / speed this up
|
40
|
+
Rails.application.config.active_record_tenanted.connection_class&.constantize
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
loader.eager_load
|
46
|
+
|
47
|
+
ActiveSupport.run_load_hooks :active_record_tenanted, ActiveRecord::Tenanted
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :db do
|
4
|
+
desc "Migrate the database for tenant ARTENANT"
|
5
|
+
task "migrate:tenant" => "load_config" do
|
6
|
+
unless ActiveRecord::Tenanted.connection_class
|
7
|
+
warn "ActiveRecord::Tenanted integration is not configured via connection_class"
|
8
|
+
next
|
9
|
+
end
|
10
|
+
|
11
|
+
unless ActiveRecord::Tenanted::DatabaseTasks.root_database_config
|
12
|
+
warn "WARNING: No tenanted database found, skipping tenanted migration"
|
13
|
+
else
|
14
|
+
begin
|
15
|
+
verbose_was = ActiveRecord::Migration.verbose
|
16
|
+
ActiveRecord::Migration.verbose = ActiveRecord::Tenanted::DatabaseTasks.verbose?
|
17
|
+
|
18
|
+
ActiveRecord::Tenanted::DatabaseTasks.migrate_tenant
|
19
|
+
ensure
|
20
|
+
ActiveRecord::Migration.verbose = verbose_was
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
desc "Migrate the database for all existing tenants"
|
26
|
+
task "migrate:tenant:all" => "load_config" do
|
27
|
+
unless ActiveRecord::Tenanted.connection_class
|
28
|
+
warn "ActiveRecord::Tenanted integration is not configured via connection_class"
|
29
|
+
next
|
30
|
+
end
|
31
|
+
|
32
|
+
verbose_was = ActiveRecord::Migration.verbose
|
33
|
+
ActiveRecord::Migration.verbose = ActiveRecord::Tenanted::DatabaseTasks.verbose?
|
34
|
+
|
35
|
+
ActiveRecord::Tenanted::DatabaseTasks.migrate_all
|
36
|
+
ensure
|
37
|
+
ActiveRecord::Migration.verbose = verbose_was
|
38
|
+
end
|
39
|
+
|
40
|
+
desc "Drop and recreate all tenant databases from their schema for the current environment"
|
41
|
+
task "reset:tenant" => [ "db:drop:tenant", "db:migrate:tenant" ]
|
42
|
+
|
43
|
+
desc "Drop all tenanted databases for the current environment"
|
44
|
+
task "drop:tenant" => "load_config" do
|
45
|
+
unless ActiveRecord::Tenanted::DatabaseTasks.root_database_config
|
46
|
+
warn "WARNING: No tenanted database found, skipping tenanted reset"
|
47
|
+
else
|
48
|
+
begin
|
49
|
+
verbose_was = ActiveRecord::Migration.verbose
|
50
|
+
ActiveRecord::Migration.verbose = ActiveRecord::Tenanted::DatabaseTasks.verbose?
|
51
|
+
|
52
|
+
ActiveRecord::Tenanted::DatabaseTasks.drop_all
|
53
|
+
ensure
|
54
|
+
ActiveRecord::Migration.verbose = verbose_was
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
desc "Set the current tenant to ARTENANT if present, else the environment default"
|
60
|
+
task "tenant" => "load_config" do
|
61
|
+
unless ActiveRecord::Tenanted.connection_class
|
62
|
+
warn "ActiveRecord::Tenanted integration is not configured via connection_class"
|
63
|
+
next
|
64
|
+
end
|
65
|
+
|
66
|
+
ActiveRecord::Tenanted::DatabaseTasks.set_current_tenant
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Decorate database tasks with the tenanted version.
|
71
|
+
task "db:migrate" => "db:migrate:tenant:all"
|
72
|
+
task "db:prepare" => "db:migrate:tenant:all"
|
73
|
+
task "db:reset" => "db:reset:tenant"
|
74
|
+
task "db:drop" => "db:drop:tenant"
|
75
|
+
|
76
|
+
# Ensure a default tenant is set for database tasks that may need it.
|
77
|
+
task "db:fixtures:load" => "db:tenant"
|
78
|
+
task "db:seed" => "db:tenant"
|