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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +46 -21
  4. data/Rakefile +5 -6
  5. data/lib/active_record/tenanted/base.rb +63 -0
  6. data/lib/active_record/tenanted/cable_connection.rb +50 -0
  7. data/lib/active_record/tenanted/connection_adapter.rb +22 -0
  8. data/lib/active_record/tenanted/console.rb +29 -0
  9. data/lib/active_record/tenanted/database_configurations.rb +166 -0
  10. data/lib/active_record/tenanted/database_tasks.rb +118 -0
  11. data/lib/active_record/tenanted/global_id.rb +36 -0
  12. data/lib/active_record/tenanted/job.rb +37 -0
  13. data/lib/active_record/tenanted/mailer.rb +15 -0
  14. data/lib/active_record/tenanted/mutex.rb +66 -0
  15. data/lib/active_record/tenanted/patches.rb +50 -0
  16. data/lib/active_record/tenanted/railtie.rb +205 -0
  17. data/lib/active_record/tenanted/relation.rb +22 -0
  18. data/lib/active_record/tenanted/storage.rb +49 -0
  19. data/lib/active_record/tenanted/subtenant.rb +36 -0
  20. data/lib/active_record/tenanted/tenant.rb +261 -0
  21. data/lib/active_record/tenanted/tenant_selector.rb +54 -0
  22. data/lib/active_record/tenanted/testing.rb +121 -0
  23. data/lib/active_record/tenanted/untenanted_connection_pool.rb +48 -0
  24. data/lib/{activerecord → active_record}/tenanted/version.rb +2 -2
  25. data/lib/active_record/tenanted.rb +47 -0
  26. data/lib/activerecord-tenanted.rb +3 -0
  27. data/lib/tasks/active_record/tenanted_tasks.rake +78 -0
  28. metadata +79 -11
  29. data/CHANGELOG.md +0 -5
  30. data/CODE_OF_CONDUCT.md +0 -132
  31. data/LICENSE.txt +0 -21
  32. data/lib/activerecord/tenanted.rb +0 -10
  33. data/sig/activerecord/tenanted.rbs +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9435f6bbc9033a529437eeb79812c04d0a2a4bd03fbc04b0e1598d4785ad4e9c
4
- data.tar.gz: 7670007efe5ce4ce8b90583781cac20794da00e44bb7dfda48226700f937fa12
3
+ metadata.gz: bec774f1cd8bd92dc8d4f0db6e76f310adbe19ccc5873e599ba0d2416f6a2dcc
4
+ data.tar.gz: bd914d09c8e8e0e5cef89523ac59b5152fd34154c7fc64f3cf6630713ae4bf82
5
5
  SHA512:
6
- metadata.gz: 99d3a88756b44268d8890dbc1185d6fc695ce7b266ed79a9cccf203ab50fb4e0b63ac15297fcc894612b89da735c1895f93cc5535f79603cb0de3e07fddd6c12
7
- data.tar.gz: be0b5c90f18a1fb8d9a3ed19a6c2e10f51e0da76975a28a92eddab20b29b8a0a993c0612f76265fbe7f04e17c4ea7f224b3feae93bab1b9ffa9bf55da58f266f
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
- # Activerecord::Tenanted
1
+ # ActiveRecord::Tenanted
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ Enable a Rails application to host multiple isolated tenants.
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/activerecord/tenanted`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- ## Installation
8
+ ## Summary
8
9
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+ ### What is "multi-tenancy"?
10
11
 
11
- Install the gem and add to the application's Gemfile by executing:
12
+ A "multi-tenant application" can be informally defined as:
12
13
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
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
- If bundler is not being used to manage dependencies, install the gem by executing:
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
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
47
+ bundle add activerecord-tenanted
21
48
  ```
22
49
 
50
+
23
51
  ## Usage
24
52
 
25
- TODO: Write usage instructions here
53
+ For detailed configuration and usage, see [GUIDE.md](./GUIDE.md).
26
54
 
27
- ## Development
28
55
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
56
+ ## Contributing
30
57
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
- ## Contributing
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/gem_tasks"
4
- require "minitest/test_task"
5
-
6
- Minitest::TestTask.create
3
+ require "bundler/setup"
7
4
 
8
- require "standard/rake"
5
+ require "bundler/gem_tasks"
9
6
 
10
- task default: %i[test standard]
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