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
@@ -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,205 @@
|
|
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
|
+
initializer "active_record_tenanted.action_dispatch", before: "action_dispatch.configure" do
|
141
|
+
config.action_dispatch.rescue_responses["ActiveRecord::Tenanted::TenantDoesNotExistError"] = :not_found
|
142
|
+
end
|
143
|
+
|
144
|
+
config.after_initialize do
|
145
|
+
if defined?(Rails::Console)
|
146
|
+
require "rails/commands/console/irb_console"
|
147
|
+
Rails::Console::IRBConsole.prepend ActiveRecord::Tenanted::Console::IRBConsole
|
148
|
+
Rails::Console::ReloadHelper.prepend ActiveRecord::Tenanted::Console::ReloadHelper
|
149
|
+
end
|
150
|
+
|
151
|
+
ActiveSupport.on_load(:action_mailbox_record) do
|
152
|
+
if Rails.application.config.active_record_tenanted.connection_class.present? &&
|
153
|
+
Rails.application.config.active_record_tenanted.tenanted_rails_records
|
154
|
+
subtenant_of Rails.application.config.active_record_tenanted.connection_class
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
ActiveSupport.on_load(:active_storage_record) do
|
159
|
+
if Rails.application.config.active_record_tenanted.connection_class.present? &&
|
160
|
+
Rails.application.config.active_record_tenanted.tenanted_rails_records
|
161
|
+
subtenant_of Rails.application.config.active_record_tenanted.connection_class
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
ActiveSupport.on_load(:action_text_record) do
|
166
|
+
if Rails.application.config.active_record_tenanted.connection_class.present? &&
|
167
|
+
Rails.application.config.active_record_tenanted.tenanted_rails_records
|
168
|
+
subtenant_of Rails.application.config.active_record_tenanted.connection_class
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
if Rails.env.test?
|
173
|
+
ActiveSupport.on_load(:active_support_test_case) do
|
174
|
+
prepend ActiveRecord::Tenanted::Testing::ActiveSupportTestCase
|
175
|
+
end
|
176
|
+
|
177
|
+
ActiveSupport.on_load(:action_dispatch_integration_test) do
|
178
|
+
prepend ActiveRecord::Tenanted::Testing::ActionDispatchIntegrationTest
|
179
|
+
ActionDispatch::Integration::Session.prepend ActiveRecord::Tenanted::Testing::ActionDispatchIntegrationSession
|
180
|
+
end
|
181
|
+
|
182
|
+
ActiveSupport.on_load(:action_dispatch_system_test_case) do
|
183
|
+
prepend ActiveRecord::Tenanted::Testing::ActionDispatchSystemTestCase
|
184
|
+
end
|
185
|
+
|
186
|
+
ActiveSupport.on_load(:active_record_fixtures) do
|
187
|
+
prepend ActiveRecord::Tenanted::Testing::ActiveRecordFixtures
|
188
|
+
end
|
189
|
+
|
190
|
+
ActiveSupport.on_load(:active_job_test_case) do
|
191
|
+
prepend ActiveRecord::Tenanted::Testing::ActiveJobTestCase
|
192
|
+
end
|
193
|
+
|
194
|
+
ActiveSupport.on_load(:action_cable_connection_test_case) do
|
195
|
+
prepend ActiveRecord::Tenanted::Testing::ActionCableTestCase
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
rake_tasks do
|
201
|
+
load File.expand_path(File.join(__dir__, "../../tasks/active_record/tenanted_tasks.rake"))
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
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
|