activerecord-tenanted 0.1.0 → 0.3.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 +205 -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 +261 -0
- data/lib/active_record/tenanted/tenant_selector.rb +54 -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 +79 -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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bec774f1cd8bd92dc8d4f0db6e76f310adbe19ccc5873e599ba0d2416f6a2dcc
|
4
|
+
data.tar.gz: bd914d09c8e8e0e5cef89523ac59b5152fd34154c7fc64f3cf6630713ae4bf82
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 54f36d6835c976757988eba57325ffe7a62e5bf53fad44fa6db9a463c4bbb42b1adea362d7c35d605cdd5cc016b0bd0b28426108efb27644a9e1279d9f6ccb72
|
7
|
+
data.tar.gz: c430f00a857613851bac9c8e81f04ae794dd90d2827e88d914c231bac218daa352b5fc0a3c21fdb82e2073e46422ebf85c310950fe4972595f4616e03374fa04
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 37signals, LLC
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
CHANGED
@@ -1,43 +1,68 @@
|
|
1
|
-
#
|
1
|
+
# ActiveRecord::Tenanted
|
2
2
|
|
3
|
-
|
3
|
+
Enable a Rails application to host multiple isolated tenants.
|
4
4
|
|
5
|
-
|
5
|
+
> [!NOTE]
|
6
|
+
> Only the sqlite3 database adapter is fully supported right now. If you have a use case for tenanting one of the other databases supported by Rails, please reach out to the maintainers!
|
6
7
|
|
7
|
-
##
|
8
|
+
## Summary
|
8
9
|
|
9
|
-
|
10
|
+
### What is "multi-tenancy"?
|
10
11
|
|
11
|
-
|
12
|
+
A "multi-tenant application" can be informally defined as:
|
12
13
|
|
13
|
-
|
14
|
-
|
15
|
-
|
14
|
+
> ... a single instance of a software application (and its underlying database and hardware)
|
15
|
+
> serv[ing] multiple tenants (or user accounts).
|
16
|
+
>
|
17
|
+
> A tenant can be an individual user, but more frequently, it’s a group of users — such as a
|
18
|
+
> customer organization — that shares common access to and privileges within the application
|
19
|
+
> instance. Each tenant’s data is isolated from, and invisible to, the other tenants sharing the
|
20
|
+
> application instance, ensuring data security and privacy for all tenants.
|
21
|
+
>
|
22
|
+
> -- [IBM.com, "What is multi-tenant?"](https://www.ibm.com/think/topics/multi-tenant)
|
23
|
+
|
24
|
+
This gem's design is rooted in a few guiding principles in order to safely allow multiple tenants to share a Rails application instance:
|
25
|
+
|
26
|
+
- Data "at rest" is persisted in a separate store for each tenant's data, isolated either physically or logically from other tenants.
|
27
|
+
- Data "in transit" is only sent to users with authenticated access to the tenant instance.
|
28
|
+
- All tenant-related code execution must happen within a well-defined isolated tenant context with controls around data access and transmission.
|
29
|
+
|
30
|
+
|
31
|
+
### Making it dead simple.
|
32
|
+
|
33
|
+
Another guiding principle, though, is:
|
34
|
+
|
35
|
+
- Developing a multi-tenant Rails app should be as easy as developing a single-tenant app.
|
36
|
+
|
37
|
+
Your code shouldn't have to know about tenanting! The hope is that you will rarely need to think about managing tenant isolation, and that as long as you're following Rails conventions, this gem and the framework will keep your tenants' data safe.
|
16
38
|
|
17
|
-
|
39
|
+
This gem extends or integrates tightly with Rails to ensure that any data persisted or transmitted happens within an isolated tenant context — without developers having to think about it.
|
40
|
+
|
41
|
+
|
42
|
+
## Installation
|
43
|
+
|
44
|
+
Install the gem and add to the application's Gemfile by executing:
|
18
45
|
|
19
46
|
```bash
|
20
|
-
|
47
|
+
bundle add activerecord-tenanted
|
21
48
|
```
|
22
49
|
|
50
|
+
|
23
51
|
## Usage
|
24
52
|
|
25
|
-
|
53
|
+
For detailed configuration and usage, see [GUIDE.md](./GUIDE.md).
|
26
54
|
|
27
|
-
## Development
|
28
55
|
|
29
|
-
|
56
|
+
## Contributing
|
30
57
|
|
31
|
-
|
58
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/basecamp/activerecord-tenanted. The tests are split between:
|
32
59
|
|
33
|
-
|
60
|
+
- fast unit tests run by `bin/test-unit`
|
61
|
+
- slower integration tests run by `bin/test-integration`
|
62
|
+
|
63
|
+
For a full test feedback loop, run `bin/ci`.
|
34
64
|
|
35
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/flavorjones/activerecord-tenanted. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/flavorjones/activerecord-tenanted/blob/main/CODE_OF_CONDUCT.md).
|
36
65
|
|
37
66
|
## License
|
38
67
|
|
39
68
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
40
|
-
|
41
|
-
## Code of Conduct
|
42
|
-
|
43
|
-
Everyone interacting in the Activerecord::Tenanted project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/flavorjones/activerecord-tenanted/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
CHANGED
@@ -1,10 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "bundler/
|
4
|
-
require "minitest/test_task"
|
5
|
-
|
6
|
-
Minitest::TestTask.create
|
3
|
+
require "bundler/setup"
|
7
4
|
|
8
|
-
require "
|
5
|
+
require "bundler/gem_tasks"
|
9
6
|
|
10
|
-
task
|
7
|
+
task :clean do
|
8
|
+
FileUtils.rm_f(Dir.glob("test/dummy/log/*.log"), verbose: true)
|
9
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
module Base
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def initialize(...)
|
10
|
+
super
|
11
|
+
|
12
|
+
@tenanted_config_name = nil
|
13
|
+
@tenanted_subtenant_of = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def tenanted(config_name = "primary")
|
17
|
+
raise Error, "Class #{self} is already tenanted" if tenanted?
|
18
|
+
raise Error, "Class #{self} is not an abstract connection class" unless abstract_class?
|
19
|
+
|
20
|
+
prepend Tenant
|
21
|
+
|
22
|
+
self.connection_class = true
|
23
|
+
@tenanted_config_name = config_name
|
24
|
+
|
25
|
+
unless tenanted_root_config.configuration_hash[:tenanted]
|
26
|
+
raise Error, "The '#{tenanted_config_name}' database is not configured as tenanted."
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def subtenant_of(class_name)
|
31
|
+
prepend Subtenant
|
32
|
+
|
33
|
+
@tenanted_subtenant_of = class_name
|
34
|
+
end
|
35
|
+
|
36
|
+
def tenanted?
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
def table_exists?
|
41
|
+
super
|
42
|
+
rescue ActiveRecord::Tenanted::NoTenantError
|
43
|
+
# If this exception was raised, then Rails is trying to determine if a non-tenanted
|
44
|
+
# table exists by accessing the tenanted primary database config, probably during eager
|
45
|
+
# loading.
|
46
|
+
#
|
47
|
+
# This happens for Record classes that late-bind to their database, like
|
48
|
+
# SolidCable::Record, SolidQueue::Record, and SolidCache::Record (all of which inherit
|
49
|
+
# directly from ActiveRecord::Base but call `connects_to` to set their database later,
|
50
|
+
# during initialization).
|
51
|
+
#
|
52
|
+
# In non-tenanted apps, this method just returns false during eager loading. So let's
|
53
|
+
# follow suit. Rails will figure it out later.
|
54
|
+
false
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def tenanted?
|
59
|
+
false
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
module CableConnection # :nodoc:
|
6
|
+
# this module is included into ActionCable::Connection::Base
|
7
|
+
module Base
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
prepended do
|
11
|
+
identified_by :current_tenant
|
12
|
+
around_command :with_tenant
|
13
|
+
end
|
14
|
+
|
15
|
+
def connect
|
16
|
+
# If Rails had a before_connect hook, this could be moved there.
|
17
|
+
set_current_tenant if connection_class && tenant_resolver
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
def set_current_tenant
|
22
|
+
return unless tenant = tenant_resolver.call(request)
|
23
|
+
|
24
|
+
if connection_class.tenant_exist?(tenant)
|
25
|
+
self.current_tenant = tenant
|
26
|
+
else
|
27
|
+
reject_unauthorized_connection
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def with_tenant(&block)
|
32
|
+
if current_tenant.present?
|
33
|
+
connection_class.with_tenant(current_tenant, &block)
|
34
|
+
else
|
35
|
+
yield
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def tenant_resolver
|
40
|
+
@tenant_resolver ||= Rails.application.config.active_record_tenanted.tenant_resolver
|
41
|
+
end
|
42
|
+
|
43
|
+
def connection_class
|
44
|
+
# TODO: cache this / speed this up
|
45
|
+
Rails.application.config.active_record_tenanted.connection_class&.constantize
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
module ConnectionAdapter # :nodoc:
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
prepended do
|
9
|
+
attr_accessor :tenant
|
10
|
+
end
|
11
|
+
|
12
|
+
def log(sql, name = "SQL", binds = [], type_casted_binds = [], async: false, allow_retry: false, &block)
|
13
|
+
name = [ name, "[tenant=#{tenant}]" ].compact.join(" ") if tenanted?
|
14
|
+
super
|
15
|
+
end
|
16
|
+
|
17
|
+
def tenanted?
|
18
|
+
tenant.present?
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
module Console # :nodoc:
|
6
|
+
module IRBConsole
|
7
|
+
def start
|
8
|
+
ActiveRecord::Tenanted::DatabaseTasks.set_current_tenant if Rails.env.local?
|
9
|
+
super
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module ReloadHelper
|
14
|
+
def execute
|
15
|
+
tenant = if Rails.env.local? && (connection_class = ActiveRecord::Tenanted.connection_class)
|
16
|
+
connection_class.current_tenant
|
17
|
+
end
|
18
|
+
|
19
|
+
super
|
20
|
+
ensure
|
21
|
+
# We need to reload the connection class to ensure that we get the new (reloaded) class.
|
22
|
+
if tenant && (connection_class = ActiveRecord::Tenanted.connection_class)
|
23
|
+
connection_class.current_tenant = tenant
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record/database_configurations"
|
4
|
+
|
5
|
+
module ActiveRecord
|
6
|
+
module Tenanted
|
7
|
+
module DatabaseConfigurations
|
8
|
+
class RootConfig < ActiveRecord::DatabaseConfigurations::HashConfig
|
9
|
+
attr_accessor :test_worker_id
|
10
|
+
|
11
|
+
def initialize(...)
|
12
|
+
super
|
13
|
+
@test_worker_id = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def database_tasks?
|
17
|
+
false
|
18
|
+
end
|
19
|
+
|
20
|
+
def database_for(tenant_name)
|
21
|
+
tenant_name = tenant_name.to_s
|
22
|
+
|
23
|
+
validate_tenant_name(tenant_name)
|
24
|
+
|
25
|
+
path = sprintf(database, tenant: tenant_name)
|
26
|
+
|
27
|
+
if test_worker_id
|
28
|
+
test_worker_path(path)
|
29
|
+
else
|
30
|
+
path
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def database_path_for(tenant_name)
|
35
|
+
coerce_path(database_for(tenant_name))
|
36
|
+
end
|
37
|
+
|
38
|
+
def tenants
|
39
|
+
glob = database_path_for("*")
|
40
|
+
scanner = Regexp.new(database_path_for("(.+)"))
|
41
|
+
|
42
|
+
Dir.glob(glob).map do |path|
|
43
|
+
result = path.scan(scanner).flatten.first
|
44
|
+
if result.nil?
|
45
|
+
warn "WARN: ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}. " \
|
46
|
+
"This is a bug, please report it to https://github.com/basecamp/activerecord-tenanted/issues"
|
47
|
+
end
|
48
|
+
result
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def new_tenant_config(tenant_name)
|
53
|
+
config_name = "#{name}_#{tenant_name}"
|
54
|
+
config_hash = configuration_hash.dup.tap do |hash|
|
55
|
+
hash[:tenant] = tenant_name
|
56
|
+
hash[:database] = database_for(tenant_name)
|
57
|
+
hash[:database_path] = database_path_for(tenant_name)
|
58
|
+
hash[:tenanted_config_name] = name
|
59
|
+
end
|
60
|
+
Tenanted::DatabaseConfigurations::TenantConfig.new(env_name, config_name, config_hash)
|
61
|
+
end
|
62
|
+
|
63
|
+
def new_connection
|
64
|
+
raise NoTenantError, "Cannot use an untenanted ActiveRecord::Base connection. " \
|
65
|
+
"If you have a model that inherits directly from ActiveRecord::Base, " \
|
66
|
+
"make sure to use 'subtenant_of'. In development, you may see this error " \
|
67
|
+
"if constant reloading is not being done properly."
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
# A sqlite database path can be a file path or a URI (either relative or absolute).
|
72
|
+
# We can't parse it as a standard URI in all circumstances, though, see https://sqlite.org/uri.html
|
73
|
+
def coerce_path(path)
|
74
|
+
if path.start_with?("file:/")
|
75
|
+
URI.parse(path).path
|
76
|
+
elsif path.start_with?("file:")
|
77
|
+
URI.parse(path.sub(/\?.*$/, "")).opaque
|
78
|
+
else
|
79
|
+
path
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def validate_tenant_name(tenant_name)
|
84
|
+
if tenant_name.match?(%r{[/'"`]})
|
85
|
+
raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def test_worker_path(path)
|
90
|
+
test_worker_suffix = "_#{test_worker_id}"
|
91
|
+
|
92
|
+
if path.start_with?("file:") && path.include?("?")
|
93
|
+
path.sub(/(\?.*)$/, "#{test_worker_suffix}\\1")
|
94
|
+
else
|
95
|
+
path + test_worker_suffix
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
class TenantConfig < ActiveRecord::DatabaseConfigurations::HashConfig
|
101
|
+
def tenant
|
102
|
+
configuration_hash.fetch(:tenant)
|
103
|
+
end
|
104
|
+
|
105
|
+
def new_connection
|
106
|
+
ensure_database_directory_exists # adapter doesn't handle this if the database is a URI
|
107
|
+
super.tap { |conn| conn.tenant = tenant }
|
108
|
+
end
|
109
|
+
|
110
|
+
def tenanted_config_name
|
111
|
+
configuration_hash.fetch(:tenanted_config_name)
|
112
|
+
end
|
113
|
+
|
114
|
+
def primary?
|
115
|
+
ActiveRecord::Base.configurations.primary?(tenanted_config_name)
|
116
|
+
end
|
117
|
+
|
118
|
+
def schema_dump(format = ActiveRecord.schema_format)
|
119
|
+
if configuration_hash.key?(:schema_dump) || primary?
|
120
|
+
super
|
121
|
+
else
|
122
|
+
"#{tenanted_config_name}_#{schema_file_type(format)}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def default_schema_cache_path(db_dir = "db")
|
127
|
+
if primary?
|
128
|
+
super
|
129
|
+
else
|
130
|
+
File.join(db_dir, "#{tenanted_config_name}_schema_cache.yml")
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def database_path
|
135
|
+
configuration_hash[:database_path]
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
def ensure_database_directory_exists
|
140
|
+
return unless database_path
|
141
|
+
|
142
|
+
database_dir = File.dirname(database_path)
|
143
|
+
unless File.directory?(database_dir)
|
144
|
+
FileUtils.mkdir_p(database_dir)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Invoked by the railtie
|
150
|
+
def self.register_db_config_handler # :nodoc:
|
151
|
+
ActiveRecord::DatabaseConfigurations.register_db_config_handler do |env_name, name, _, config|
|
152
|
+
next unless config.fetch(:tenanted, false)
|
153
|
+
|
154
|
+
ActiveRecord::Tenanted::DatabaseConfigurations::RootConfig.new(env_name, name, config)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Do this here instead of the railtie so we register the handlers before Rails's rake tasks get
|
162
|
+
# loaded. If the handler is not present, then the RootConfigs will not return false from
|
163
|
+
# `#database_tasks?` and the database tasks will get created anyway.
|
164
|
+
#
|
165
|
+
# TODO: This can be moved back into the railtie if https://github.com/rails/rails/pull/54959 is merged.
|
166
|
+
ActiveRecord::Tenanted::DatabaseConfigurations.register_db_config_handler
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
module DatabaseTasks # :nodoc:
|
6
|
+
extend self
|
7
|
+
|
8
|
+
def migrate_all
|
9
|
+
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
|
10
|
+
|
11
|
+
tenants = root_config.tenants.presence || [ get_current_tenant ].compact
|
12
|
+
tenants.each do |tenant|
|
13
|
+
tenant_config = root_config.new_tenant_config(tenant)
|
14
|
+
migrate(tenant_config)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def migrate_tenant(tenant_name = set_current_tenant)
|
19
|
+
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
|
20
|
+
|
21
|
+
tenant_config = root_config.new_tenant_config(tenant_name)
|
22
|
+
|
23
|
+
migrate(tenant_config)
|
24
|
+
end
|
25
|
+
|
26
|
+
def drop_all
|
27
|
+
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
|
28
|
+
|
29
|
+
root_config.tenants.each do |tenant|
|
30
|
+
# NOTE: This is obviously a sqlite-specific implementation.
|
31
|
+
# TODO: Create a `drop_database` method upstream in the sqlite3 adapter, and call it.
|
32
|
+
# Then this would delegate to the adapter and become adapter-agnostic.
|
33
|
+
root_config.database_path_for(tenant).tap do |path|
|
34
|
+
FileUtils.rm(path)
|
35
|
+
$stdout.puts "Dropped database '#{path}'" if verbose?
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def root_database_config
|
41
|
+
db_configs = ActiveRecord::Base.configurations.configs_for(
|
42
|
+
env_name: ActiveRecord::Tasks::DatabaseTasks.env,
|
43
|
+
include_hidden: true
|
44
|
+
)
|
45
|
+
db_configs.detect { |c| c.configuration_hash[:tenanted] }
|
46
|
+
end
|
47
|
+
|
48
|
+
def get_current_tenant
|
49
|
+
tenant = ENV["ARTENANT"]
|
50
|
+
|
51
|
+
if tenant.present?
|
52
|
+
$stdout.puts "Setting current tenant to #{tenant.inspect}" if verbose?
|
53
|
+
elsif Rails.env.local?
|
54
|
+
tenant = Rails.application.config.active_record_tenanted.default_tenant
|
55
|
+
$stdout.puts "Defaulting current tenant to #{tenant.inspect}" if verbose?
|
56
|
+
else
|
57
|
+
tenant = nil
|
58
|
+
$stdout.puts "Cannot determine an implicit tenant: ARTENANT not set, and Rails.env is not local." if verbose?
|
59
|
+
end
|
60
|
+
|
61
|
+
tenant
|
62
|
+
end
|
63
|
+
|
64
|
+
def set_current_tenant
|
65
|
+
unless (connection_class = ActiveRecord::Tenanted.connection_class)
|
66
|
+
raise ActiveRecord::Tenanted::IntegrationNotConfiguredError,
|
67
|
+
"ActiveRecord::Tenanted integration is not configured via connection_class"
|
68
|
+
end
|
69
|
+
|
70
|
+
if connection_class.current_tenant.nil?
|
71
|
+
connection_class.current_tenant = get_current_tenant
|
72
|
+
else
|
73
|
+
connection_class.current_tenant
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# This is essentially a simplified implementation of ActiveRecord::Tasks::DatabaseTasks.migrate
|
78
|
+
def migrate(config)
|
79
|
+
ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(config) do |conn|
|
80
|
+
pool = conn.pool
|
81
|
+
|
82
|
+
# initialize_database
|
83
|
+
unless pool.schema_migration.table_exists?
|
84
|
+
schema_dump_path = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(config)
|
85
|
+
if schema_dump_path && File.exist?(schema_dump_path)
|
86
|
+
ActiveRecord::Tasks::DatabaseTasks.load_schema(config)
|
87
|
+
end
|
88
|
+
# TODO: emit a "Created database" message once we sort out implicit creation
|
89
|
+
end
|
90
|
+
|
91
|
+
# migrate
|
92
|
+
migrated = false
|
93
|
+
if pool.migration_context.pending_migration_versions.present?
|
94
|
+
pool.migration_context.migrate(nil)
|
95
|
+
pool.schema_cache.clear!
|
96
|
+
migrated = true
|
97
|
+
end
|
98
|
+
|
99
|
+
# dump the schema and schema cache
|
100
|
+
if Rails.env.development? || ENV["ARTENANT_SCHEMA_DUMP"].present?
|
101
|
+
if migrated
|
102
|
+
ActiveRecord::Tasks::DatabaseTasks.dump_schema(config)
|
103
|
+
end
|
104
|
+
|
105
|
+
cache_dump = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(config)
|
106
|
+
if migrated || !File.exist?(cache_dump)
|
107
|
+
ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache(pool, cache_dump)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def verbose?
|
114
|
+
ActiveRecord::Tasks::DatabaseTasks.send(:verbose?)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "globalid"
|
4
|
+
|
5
|
+
module ActiveRecord
|
6
|
+
module Tenanted
|
7
|
+
module GlobalId
|
8
|
+
def tenant
|
9
|
+
params && params[:tenant]
|
10
|
+
end
|
11
|
+
|
12
|
+
class Locator
|
13
|
+
def locate(gid, options = {})
|
14
|
+
ensure_tenant_context_safety(gid)
|
15
|
+
gid.model_class.find(gid.model_id)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
def ensure_tenant_context_safety(gid)
|
20
|
+
model_class = gid.model_class
|
21
|
+
return unless model_class.tenanted?
|
22
|
+
|
23
|
+
gid_tenant = gid.tenant
|
24
|
+
raise MissingTenantError, "Tenant not present in #{gid.to_s.inspect}" unless gid_tenant
|
25
|
+
|
26
|
+
current_tenant = model_class.current_tenant.presence
|
27
|
+
raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{gid})" unless current_tenant
|
28
|
+
|
29
|
+
if gid_tenant != current_tenant
|
30
|
+
raise WrongTenantError, "GlobalID #{gid.to_s.inspect} does not belong the current tenant #{current_tenant.inspect}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module Tenanted
|
5
|
+
module Job # :nodoc:
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
prepended do
|
9
|
+
attr_reader :tenant
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(...)
|
13
|
+
super
|
14
|
+
if klass = ActiveRecord::Tenanted.connection_class
|
15
|
+
@tenant = klass.current_tenant
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def serialize
|
20
|
+
super.merge!({ "tenant" => tenant })
|
21
|
+
end
|
22
|
+
|
23
|
+
def deserialize(job_data)
|
24
|
+
super
|
25
|
+
@tenant = job_data.fetch("tenant", nil)
|
26
|
+
end
|
27
|
+
|
28
|
+
def perform_now
|
29
|
+
if tenant.present? && (klass = ActiveRecord::Tenanted.connection_class)
|
30
|
+
klass.with_tenant(tenant) { super }
|
31
|
+
else
|
32
|
+
super
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|