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.
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 +201 -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 +257 -0
  21. data/lib/active_record/tenanted/tenant_selector.rb +57 -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 +93 -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
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ module Mailer
6
+ def url_options(...)
7
+ super.tap do |options|
8
+ if ActiveRecord::Tenanted.connection_class && options.key?(:host)
9
+ options[:host] = sprintf(options[:host], tenant: ActiveRecord::Tenanted.connection_class.current_tenant)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ module Mutex
6
+ #
7
+ # This flock-based mutex is intended to hold a lock while the tenant database is in the
8
+ # process of being created and migrated (i.e., "made ready").
9
+ #
10
+ # The creation-and-migration time span is generally very short, and only happens once at the
11
+ # beginning of the database file's existence. We can take advantage of these characteristics
12
+ # to make sure the readiness check is cheap for the majority of the database's life.
13
+ #
14
+ # 1. The lock file is created and an advisory lock is acquired before the database file is
15
+ # created.
16
+ # 2. Once the database migration has been completed and the database is ready, the lock is
17
+ # released and the file is removed.
18
+ #
19
+ # If the lock file exists, then a relatively expensive shared lock must be acquired to ensure
20
+ # the database is ready to use. However, if the lock file does not exist (the majority of the
21
+ # database's life!) then the readiness check is a cheap check for existing on the database
22
+ # file.
23
+ #
24
+ class Ready
25
+ class << self
26
+ def lock(database_path)
27
+ path = lock_file_path(database_path)
28
+ FileUtils.mkdir_p(File.dirname(path))
29
+
30
+ # mode "w" to create the file if it does not exist.
31
+ File.open(path, "w") do |f|
32
+ f.flock(File::LOCK_EX) # blocking!
33
+ yield
34
+ ensure
35
+ File.unlink(path)
36
+ end
37
+ end
38
+
39
+ def locked?(database_path)
40
+ path = lock_file_path(database_path)
41
+
42
+ if File.exist?(path)
43
+ result = nil
44
+ begin
45
+ # mode "r" to avoid creating the file if it does not exist.
46
+ File.open(path, "r") do |f|
47
+ result = f.flock(File::LOCK_SH | File::LOCK_NB)
48
+ end
49
+ result != 0
50
+ rescue Errno::ENOENT
51
+ # the file was deleted between the existence check and the open
52
+ false
53
+ end
54
+ else
55
+ false
56
+ end
57
+ end
58
+
59
+ def lock_file_path(database_path)
60
+ database_path.to_s + ".ready_lock"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ module Patches
6
+ # TODO: I think this is needed because there was no followup to rails/rails#46270.
7
+ # See rails/rails@901828f2 from that PR for background.
8
+ module DatabaseTasks
9
+ private
10
+ def with_temporary_pool(db_config, clobber: false)
11
+ original_db_config = begin
12
+ migration_class.connection_db_config
13
+ rescue ActiveRecord::ConnectionNotDefined
14
+ nil
15
+ end
16
+
17
+ begin
18
+ pool = migration_class.connection_handler.establish_connection(db_config, clobber: clobber)
19
+
20
+ yield pool
21
+ ensure
22
+ migration_class.connection_handler.establish_connection(original_db_config, clobber: clobber) if original_db_config
23
+ end
24
+ end
25
+ end
26
+
27
+ # TODO: This monkey patch shouldn't be necessary after 8.1 lands and the need for a
28
+ # connection is removed. For details see https://github.com/rails/rails/pull/54348
29
+ module Attributes
30
+ extend ActiveSupport::Concern
31
+
32
+ class_methods do
33
+ def _default_attributes # :nodoc:
34
+ @default_attributes ||= begin
35
+ # I've removed the `with_connection` block here.
36
+ nil_connection = nil
37
+ attributes_hash = columns_hash.transform_values do |column|
38
+ ActiveModel::Attribute.from_database(column.name, column.default, type_for_column(nil_connection, column))
39
+ end
40
+
41
+ attribute_set = ActiveModel::AttributeSet.new(attributes_hash)
42
+ apply_pending_attribute_modifications(attribute_set)
43
+ attribute_set
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ class Railtie < ::Rails::Railtie
6
+ config.active_record_tenanted = ActiveSupport::OrderedOptions.new
7
+
8
+ # Set this in an initializer if you're tenanting a connection class other than
9
+ # ApplicationRecord. This value indicates the connection class that this gem uses to integrate
10
+ # with a broad set of Rails subsystems, including:
11
+ #
12
+ # - Active Job
13
+ # - Active Storage
14
+ # - Action Cable
15
+ # - Action Dispatch middleware (Tenant Selector)
16
+ # - Test frameworks and fixtures
17
+ #
18
+ # Defaults to "ApplicationRecord", but this can be set to `nil` to turn off the integrations
19
+ # entirely.
20
+ config.active_record_tenanted.connection_class = "ApplicationRecord"
21
+
22
+ # Set this to a lambda that takes a request object and returns the tenant name. It's used by:
23
+ #
24
+ # - Action Dispatch middleware (Tenant Selector)
25
+ # - Action Cable connections
26
+ #
27
+ # Defaults to the request subdomain.
28
+ config.active_record_tenanted.tenant_resolver = ->(request) { request.subdomain }
29
+
30
+ # Set this to false in an initializer if you don't want Rails records to share a connection
31
+ # pool with the tenanted connection class.
32
+ #
33
+ # By default, this gem will configure ActionMailbox::Record, ActiveStorage::Record, and
34
+ # ActionText::Record to create/use tables in the database associated with the
35
+ # `connection_class`, and will share a connection pool with that class.
36
+ #
37
+ # This should only be turned off if your primary database configuration is not tenanted, and
38
+ # that is where you want Rails to create the tables for these records.
39
+ #
40
+ # Defaults to `true`.
41
+ config.active_record_tenanted.tenanted_rails_records = true
42
+
43
+ # Set this to control whether the Rails logger will include the tenant name in a tag in each
44
+ # log line.
45
+ #
46
+ # Defaults to false in development and test environments, and true in all other environments.
47
+ config.active_record_tenanted.log_tenant_tag = !Rails.env.local?
48
+
49
+ # Set this to override the default tenant name used in development and test environments.
50
+ #
51
+ # This is the default tenant name used by database tasks and in the Rails console. In both
52
+ # cases, this can be overridden at runtime by setting the `ARTENANT` environment variable.
53
+ #
54
+ # Notably, it's also the tenant name used by the testing frameworks, so you may need to set
55
+ # this if you have application-specific constraints on tenant names.
56
+ #
57
+ # Defaults to "development-tenant" in development and "test-tenant" in test environments.
58
+ config.active_record_tenanted.default_tenant = Rails.env.local? ? "#{Rails.env}-tenant" : nil
59
+
60
+ config.before_initialize do
61
+ Rails.application.configure do
62
+ if config.active_record_tenanted.connection_class.present?
63
+ config.middleware.use ActiveRecord::Tenanted::TenantSelector
64
+ end
65
+ end
66
+ end
67
+
68
+ initializer "active_record_tenanted.active_record_base" do
69
+ ActiveSupport.on_load(:active_record) do
70
+ prepend ActiveRecord::Tenanted::Base
71
+ ActiveRecord::Relation.prepend ActiveRecord::Tenanted::Relation
72
+ end
73
+ end
74
+
75
+ initializer("active_record_tenanted.active_record_schema_cache",
76
+ before: "active_record.copy_schema_cache_config") do
77
+ # Rails must be able to load the schema for a tenanted model without a database connection
78
+ # (e.g., boot-time eager loading, or calling User.new to build a form). This gem relies on
79
+ # reading from the schema cache dump to do that.
80
+ #
81
+ # Rails defaults use_schema_cache_dump to true, but we explicitly re-set it here because if
82
+ # this is ever turned off, Rails will not work as expected.
83
+ config.active_record.use_schema_cache_dump = true
84
+
85
+ # The schema cache version check needs to query the database, which isn't always possible
86
+ # for tenanted models.
87
+ config.active_record.check_schema_cache_dump_version = false
88
+ end
89
+
90
+ initializer "active_record_tenanted.monkey_patches" do
91
+ ActiveSupport.on_load(:active_record) do
92
+ prepend ActiveRecord::Tenanted::Patches::Attributes
93
+ ActiveRecord::Tasks::DatabaseTasks.prepend ActiveRecord::Tenanted::Patches::DatabaseTasks
94
+ end
95
+ end
96
+
97
+ initializer "active_record_tenanted.active_job" do
98
+ ActiveSupport.on_load(:active_job) do
99
+ prepend ActiveRecord::Tenanted::Job
100
+ end
101
+ end
102
+
103
+ initializer "active_record_tenanted.action_cable_connection" do
104
+ ActiveSupport.on_load(:action_cable_connection) do
105
+ prepend ActiveRecord::Tenanted::CableConnection::Base
106
+ end
107
+ end
108
+
109
+ initializer "active_record_tenanted.global_id", after: "global_id" do
110
+ ::GlobalID.prepend ActiveRecord::Tenanted::GlobalId
111
+ ::GlobalID::Locator.use GlobalID.app, ActiveRecord::Tenanted::GlobalId::Locator.new
112
+ end
113
+
114
+ initializer "active_record_tenanted.active_storage", after: "active_storage.services" do
115
+ # TODO: Add a hook for Disk Service. Without that, there's no good way to include this
116
+ # module into the class before the service is initialized.
117
+ # As a workaround, explicitly require this file.
118
+ require "active_storage/service/disk_service"
119
+ ActiveStorage::Service::DiskService.prepend ActiveRecord::Tenanted::Storage::DiskService
120
+ end
121
+
122
+ initializer "active_record_tenanted.active_storage_blob" do
123
+ ActiveSupport.on_load(:active_storage_blob) do
124
+ prepend ActiveRecord::Tenanted::Storage::Blob
125
+ end
126
+ end
127
+
128
+ initializer "active_record_tenanted.active_record_connection_adapter" do
129
+ ActiveSupport.on_load(:active_record) do
130
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend ActiveRecord::Tenanted::ConnectionAdapter
131
+ end
132
+ end
133
+
134
+ initializer "active_record_tenanted.action_mailer" do
135
+ ActiveSupport.on_load(:action_mailer) do
136
+ prepend ActiveRecord::Tenanted::Mailer
137
+ end
138
+ end
139
+
140
+ config.after_initialize do
141
+ if defined?(Rails::Console)
142
+ require "rails/commands/console/irb_console"
143
+ Rails::Console::IRBConsole.prepend ActiveRecord::Tenanted::Console::IRBConsole
144
+ Rails::Console::ReloadHelper.prepend ActiveRecord::Tenanted::Console::ReloadHelper
145
+ end
146
+
147
+ ActiveSupport.on_load(:action_mailbox_record) do
148
+ if Rails.application.config.active_record_tenanted.connection_class.present? &&
149
+ Rails.application.config.active_record_tenanted.tenanted_rails_records
150
+ subtenant_of Rails.application.config.active_record_tenanted.connection_class
151
+ end
152
+ end
153
+
154
+ ActiveSupport.on_load(:active_storage_record) do
155
+ if Rails.application.config.active_record_tenanted.connection_class.present? &&
156
+ Rails.application.config.active_record_tenanted.tenanted_rails_records
157
+ subtenant_of Rails.application.config.active_record_tenanted.connection_class
158
+ end
159
+ end
160
+
161
+ ActiveSupport.on_load(:action_text_record) do
162
+ if Rails.application.config.active_record_tenanted.connection_class.present? &&
163
+ Rails.application.config.active_record_tenanted.tenanted_rails_records
164
+ subtenant_of Rails.application.config.active_record_tenanted.connection_class
165
+ end
166
+ end
167
+
168
+ if Rails.env.test?
169
+ ActiveSupport.on_load(:active_support_test_case) do
170
+ prepend ActiveRecord::Tenanted::Testing::ActiveSupportTestCase
171
+ end
172
+
173
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
174
+ prepend ActiveRecord::Tenanted::Testing::ActionDispatchIntegrationTest
175
+ ActionDispatch::Integration::Session.prepend ActiveRecord::Tenanted::Testing::ActionDispatchIntegrationSession
176
+ end
177
+
178
+ ActiveSupport.on_load(:action_dispatch_system_test_case) do
179
+ prepend ActiveRecord::Tenanted::Testing::ActionDispatchSystemTestCase
180
+ end
181
+
182
+ ActiveSupport.on_load(:active_record_fixtures) do
183
+ prepend ActiveRecord::Tenanted::Testing::ActiveRecordFixtures
184
+ end
185
+
186
+ ActiveSupport.on_load(:active_job_test_case) do
187
+ prepend ActiveRecord::Tenanted::Testing::ActiveJobTestCase
188
+ end
189
+
190
+ ActiveSupport.on_load(:action_cable_connection_test_case) do
191
+ prepend ActiveRecord::Tenanted::Testing::ActionCableTestCase
192
+ end
193
+ end
194
+ end
195
+
196
+ rake_tasks do
197
+ load File.expand_path(File.join(__dir__, "../../tasks/active_record/tenanted_tasks.rake"))
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ module Relation # :nodoc:
6
+ def initialize(...)
7
+ super
8
+ @tenant = @model.current_tenant if @model.tenanted?
9
+ end
10
+
11
+ def instantiate_records(...)
12
+ super.tap do |records|
13
+ if @tenant
14
+ records.each do |record|
15
+ record.instance_variable_set(:@tenant, @tenant)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ module Storage # :nodoc:
6
+ module DiskService
7
+ def root
8
+ if klass = ActiveRecord::Tenanted.connection_class
9
+ unless tenant = klass.current_tenant
10
+ raise NoTenantError, "Cannot access Active Storage Disk service without a tenant"
11
+ end
12
+
13
+ sprintf(@root, tenant: tenant)
14
+ else
15
+ super
16
+ end
17
+ end
18
+
19
+ def path_for(key)
20
+ if ActiveRecord::Tenanted.connection_class
21
+ # TODO: this is brittle if the key isn't tenanted ... errors in folder_for:
22
+ #
23
+ # NoMethodError undefined method '[]' for nil (NoMethodError) [ key[0..1], key[2..3] ].join("/")
24
+ #
25
+ tenant, key = key.split("/", 2)
26
+ File.join(root, tenant, folder_for(key), key)
27
+ else
28
+ super
29
+ end
30
+ end
31
+ end
32
+
33
+ module Blob
34
+ def key
35
+ self[:key] ||= if klass = ActiveRecord::Tenanted.connection_class
36
+ unless tenant = klass.current_tenant
37
+ raise NoTenantError, "Cannot generate a Blob key without a tenant"
38
+ end
39
+
40
+ token = self.class.generate_unique_secure_token(length: ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH)
41
+ [ tenant, token ].join("/")
42
+ else
43
+ super
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ module Subtenant
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def tenanted?
10
+ true
11
+ end
12
+
13
+ def tenanted_subtenant_of
14
+ # TODO: cache this / speed this up
15
+ # but note that we should constantize as late as possible to avoid load order issues
16
+ klass = @tenanted_subtenant_of&.constantize || superclass.tenanted_subtenant_of
17
+
18
+ raise Error, "Class #{klass} is not tenanted" unless klass.tenanted?
19
+ raise Error, "Class #{klass} is not a connection class" unless klass.abstract_class?
20
+
21
+ klass
22
+ end
23
+
24
+ delegate :current_tenant, :connection_pool, to: :tenanted_subtenant_of
25
+ end
26
+
27
+ prepended do
28
+ prepend TenantCommon
29
+ end
30
+
31
+ def tenanted?
32
+ true
33
+ end
34
+ end
35
+ end
36
+ end