activerecord-multi-tenant 2.0.0 → 2.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/active-record-multi-tenant-tests.yml +83 -0
- data/.gitignore +6 -0
- data/.readthedocs.yaml +15 -0
- data/.rspec +0 -0
- data/.rubocop.yml +51 -0
- data/Appraisals +6 -22
- data/CHANGELOG.md +42 -0
- data/Gemfile +3 -1
- data/LICENSE +0 -0
- data/README.md +3 -2
- data/Rakefile +1 -1
- data/activerecord-multi-tenant.gemspec +28 -22
- data/docker-compose.yml +24 -18
- data/docs/.gitignore +3 -0
- data/docs/Makefile +28 -0
- data/docs/api-reference.sh +10 -0
- data/docs/requirements.in +4 -0
- data/docs/requirements.txt +62 -0
- data/docs/source/_static/api-reference/ActiveRecord/Associations/Association.html +285 -0
- data/docs/source/_static/api-reference/ActiveRecord/Associations/ClassMethods.html +255 -0
- data/docs/source/_static/api-reference/ActiveRecord/Associations.html +117 -0
- data/docs/source/_static/api-reference/ActiveRecord/ConnectionAdapters/SchemaStatements.html +232 -0
- data/docs/source/_static/api-reference/ActiveRecord/ConnectionAdapters.html +126 -0
- data/docs/source/_static/api-reference/ActiveRecord/QueryMethods.html +336 -0
- data/docs/source/_static/api-reference/ActiveRecord/SchemaDumper.html +121 -0
- data/docs/source/_static/api-reference/ActiveRecord.html +130 -0
- data/docs/source/_static/api-reference/MultiTenant/ArelTenantVisitor.html +755 -0
- data/docs/source/_static/api-reference/MultiTenant/ArelVisitorsDepthFirst.html +208 -0
- data/docs/source/_static/api-reference/MultiTenant/BaseTenantEnforcementClause.html +462 -0
- data/docs/source/_static/api-reference/MultiTenant/Context.html +659 -0
- data/docs/source/_static/api-reference/MultiTenant/ControllerExtensions.html +202 -0
- data/docs/source/_static/api-reference/MultiTenant/CopyFromClient.html +186 -0
- data/docs/source/_static/api-reference/MultiTenant/CopyFromClientHelper.html +362 -0
- data/docs/source/_static/api-reference/MultiTenant/Current.html +124 -0
- data/docs/source/_static/api-reference/MultiTenant/DatabaseStatements.html +366 -0
- data/docs/source/_static/api-reference/MultiTenant/FastTruncate.html +226 -0
- data/docs/source/_static/api-reference/MultiTenant/MigrationExtensions.html +554 -0
- data/docs/source/_static/api-reference/MultiTenant/MissingTenantError.html +124 -0
- data/docs/source/_static/api-reference/MultiTenant/ModelExtensionsClassMethods.html +492 -0
- data/docs/source/_static/api-reference/MultiTenant/QueryMonitor.html +257 -0
- data/docs/source/_static/api-reference/MultiTenant/Table.html +419 -0
- data/docs/source/_static/api-reference/MultiTenant/TenantEnforcementClause.html +148 -0
- data/docs/source/_static/api-reference/MultiTenant/TenantIsImmutable.html +135 -0
- data/docs/source/_static/api-reference/MultiTenant/TenantJoinEnforcementClause.html +310 -0
- data/docs/source/_static/api-reference/MultiTenant/TenantValueVisitor.html +239 -0
- data/docs/source/_static/api-reference/MultiTenant.html +1454 -0
- data/docs/source/_static/api-reference/MultiTenantFindBy.html +180 -0
- data/docs/source/_static/api-reference/Sidekiq/Client.html +302 -0
- data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant/Client.html +217 -0
- data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant/Server.html +219 -0
- data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant.html +126 -0
- data/docs/source/_static/api-reference/Sidekiq.html +126 -0
- data/docs/source/_static/api-reference/_index.html +399 -0
- data/docs/source/_static/api-reference/class_list.html +51 -0
- data/docs/source/_static/api-reference/css/common.css +1 -0
- data/docs/source/_static/api-reference/css/full_list.css +58 -0
- data/docs/source/_static/api-reference/css/style.css +497 -0
- data/docs/source/_static/api-reference/file.README.html +167 -0
- data/docs/source/_static/api-reference/file_list.html +56 -0
- data/docs/source/_static/api-reference/frames.html +17 -0
- data/docs/source/_static/api-reference/index.html +167 -0
- data/docs/source/_static/api-reference/js/app.js +314 -0
- data/docs/source/_static/api-reference/js/full_list.js +216 -0
- data/docs/source/_static/api-reference/js/jquery.js +4 -0
- data/docs/source/_static/api-reference/method_list.html +715 -0
- data/docs/source/_static/api-reference/top-level-namespace.html +126 -0
- data/docs/source/_templates/.gitignore +4 -0
- data/docs/source/api-reference.rst +8 -0
- data/docs/source/appendix.rst +26 -0
- data/docs/source/changelog.rst +8 -0
- data/docs/source/community-and-support.rst +26 -0
- data/docs/source/conf.py +30 -0
- data/docs/source/contributing.rst +70 -0
- data/docs/source/getting-started.rst +37 -0
- data/docs/source/guides-and-tutorials.rst +129 -0
- data/docs/source/index.rst +54 -0
- data/docs/source/introduction.rst +33 -0
- data/docs/source/license.rst +22 -0
- data/docs/source/troubleshooting.rst +41 -0
- data/docs/source/usage-guide.rst +59 -0
- data/lib/activerecord-multi-tenant/arel_visitors_depth_first.rb +183 -174
- data/lib/activerecord-multi-tenant/controller_extensions.rb +15 -4
- data/lib/activerecord-multi-tenant/copy_from_client.rb +4 -0
- data/lib/activerecord-multi-tenant/fast_truncate.rb +4 -2
- data/lib/activerecord-multi-tenant/habtm.rb +50 -0
- data/lib/activerecord-multi-tenant/migrations.rb +87 -10
- data/lib/activerecord-multi-tenant/model_extensions.rb +96 -38
- data/lib/activerecord-multi-tenant/multi_tenant.rb +83 -24
- data/lib/activerecord-multi-tenant/query_monitor.rb +21 -5
- data/lib/activerecord-multi-tenant/query_rewriter.rb +121 -87
- data/lib/activerecord-multi-tenant/sidekiq.rb +46 -19
- data/lib/activerecord-multi-tenant/table_node.rb +13 -0
- data/lib/activerecord-multi-tenant/version.rb +1 -1
- data/lib/activerecord-multi-tenant.rb +3 -13
- data/lib/activerecord_multi_tenant.rb +13 -0
- data/spec/activerecord-multi-tenant/associations_spec.rb +42 -0
- data/spec/activerecord-multi-tenant/controller_extensions_spec.rb +3 -2
- data/spec/activerecord-multi-tenant/fast_truncate_spec.rb +8 -6
- data/spec/activerecord-multi-tenant/model_extensions_spec.rb +300 -147
- data/spec/activerecord-multi-tenant/multi_tenant_spec.rb +69 -13
- data/spec/activerecord-multi-tenant/query_rewriter_spec.rb +60 -59
- data/spec/activerecord-multi-tenant/record_callback_spec.rb +0 -0
- data/spec/activerecord-multi-tenant/record_finding_spec.rb +11 -11
- data/spec/activerecord-multi-tenant/record_modifications_spec.rb +23 -4
- data/spec/activerecord-multi-tenant/sidekiq_spec.rb +10 -10
- data/spec/database.yml +0 -0
- data/spec/schema.rb +20 -3
- data/spec/spec_helper.rb +46 -17
- data/spec/support/format_sql.rb +20 -0
- metadata +134 -32
- data/.github/workflows/CI.yml +0 -73
- data/gemfiles/.bundle/config +0 -2
- data/gemfiles/active_record_5.2.3.gemfile +0 -16
- data/gemfiles/active_record_5.2.gemfile +0 -16
- data/gemfiles/active_record_6.0.gemfile +0 -8
- data/gemfiles/active_record_6.1.gemfile +0 -8
- data/gemfiles/active_record_7.0.gemfile +0 -8
- data/gemfiles/rails_5.2.3.gemfile +0 -16
- data/gemfiles/rails_5.2.gemfile +0 -16
- data/gemfiles/rails_6.0.gemfile +0 -8
- data/gemfiles/rails_6.1.gemfile +0 -8
- data/gemfiles/rails_7.0.gemfile +0 -8
- data/lib/activerecord-multi-tenant/persistence_extension.rb +0 -13
- data/lib/activerecord-multi-tenant/with_lock.rb +0 -15
- data/spec/activerecord-multi-tenant/schema_dumper_tester.rb +0 -0
@@ -2,12 +2,40 @@ module MultiTenant
|
|
2
2
|
module MigrationExtensions
|
3
3
|
def create_distributed_table(table_name, partition_key)
|
4
4
|
return unless citus_version.present?
|
5
|
-
|
5
|
+
|
6
|
+
reversible do |dir|
|
7
|
+
dir.up do
|
8
|
+
execute "SELECT create_distributed_table($$#{table_name}$$, $$#{partition_key}$$)"
|
9
|
+
end
|
10
|
+
dir.down do
|
11
|
+
undistribute_table(table_name)
|
12
|
+
end
|
13
|
+
end
|
6
14
|
end
|
7
15
|
|
8
16
|
def create_reference_table(table_name)
|
9
17
|
return unless citus_version.present?
|
10
|
-
|
18
|
+
|
19
|
+
reversible do |dir|
|
20
|
+
dir.up do
|
21
|
+
execute "SELECT create_reference_table($$#{table_name}$$)"
|
22
|
+
end
|
23
|
+
dir.down do
|
24
|
+
undistribute_table(table_name)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def undistribute_table(table_name)
|
30
|
+
return unless citus_version.present?
|
31
|
+
|
32
|
+
execute "SELECT undistribute_table($$#{table_name}$$))"
|
33
|
+
end
|
34
|
+
|
35
|
+
def rebalance_table_shards
|
36
|
+
return unless citus_version.present?
|
37
|
+
|
38
|
+
execute 'SELECT rebalance_table_shards()'
|
11
39
|
end
|
12
40
|
|
13
41
|
def execute_on_all_nodes(sql)
|
@@ -18,7 +46,8 @@ module MultiTenant
|
|
18
46
|
execute "SELECT citus_run_on_all_workers($$#{sql}$$)" # initial citus_tools.sql with different names
|
19
47
|
when nil
|
20
48
|
# Do nothing, this is regular Postgres
|
21
|
-
else
|
49
|
+
else
|
50
|
+
# 6.1 and newer
|
22
51
|
execute "SELECT run_command_on_workers($$#{sql}$$)"
|
23
52
|
end
|
24
53
|
end
|
@@ -28,24 +57,23 @@ module MultiTenant
|
|
28
57
|
end
|
29
58
|
|
30
59
|
def citus_version
|
31
|
-
execute("SELECT extversion FROM pg_extension WHERE extname = 'citus'").getvalue(0,0).try(:split, '-').try(:first)
|
60
|
+
execute("SELECT extversion FROM pg_extension WHERE extname = 'citus'").getvalue(0, 0).try(:split, '-').try(:first)
|
32
61
|
rescue ArgumentError => e
|
33
|
-
raise unless e.message ==
|
62
|
+
raise unless e.message == 'invalid tuple number 0'
|
34
63
|
end
|
35
64
|
end
|
36
65
|
end
|
37
66
|
|
38
|
-
if defined?(ActiveRecord::Migration)
|
39
|
-
ActiveRecord::Migration.send(:include, MultiTenant::MigrationExtensions)
|
40
|
-
end
|
67
|
+
ActiveRecord::Migration.include MultiTenant::MigrationExtensions if defined?(ActiveRecord::Migration)
|
41
68
|
|
42
69
|
module ActiveRecord
|
43
70
|
module ConnectionAdapters # :nodoc:
|
44
71
|
module SchemaStatements
|
45
|
-
alias
|
72
|
+
alias orig_create_table create_table
|
73
|
+
|
46
74
|
def create_table(table_name, options = {}, &block)
|
47
75
|
ret = orig_create_table(table_name, **options.except(:partition_key), &block)
|
48
|
-
if options[:partition_key] && options[:partition_key].to_s != 'id'
|
76
|
+
if options[:id] != false && options[:partition_key] && options[:partition_key].to_s != 'id'
|
49
77
|
execute "ALTER TABLE #{table_name} DROP CONSTRAINT #{table_name}_pkey"
|
50
78
|
execute "ALTER TABLE #{table_name} ADD PRIMARY KEY(\"#{options[:partition_key]}\", id)"
|
51
79
|
end
|
@@ -54,3 +82,52 @@ module ActiveRecord
|
|
54
82
|
end
|
55
83
|
end
|
56
84
|
end
|
85
|
+
|
86
|
+
module ActiveRecord
|
87
|
+
class SchemaDumper
|
88
|
+
private
|
89
|
+
|
90
|
+
alias initialize_without_citus initialize
|
91
|
+
|
92
|
+
def initialize(connection, options = {})
|
93
|
+
initialize_without_citus(connection, options)
|
94
|
+
|
95
|
+
citus_version =
|
96
|
+
begin
|
97
|
+
ActiveRecord::Migration.citus_version
|
98
|
+
rescue StandardError
|
99
|
+
# Handle the case where this gem is used with MySQL https://github.com/citusdata/activerecord-multi-tenant/issues/166
|
100
|
+
nil
|
101
|
+
end
|
102
|
+
@distribution_columns =
|
103
|
+
if citus_version.present?
|
104
|
+
query_to_execute = <<-SQL.strip
|
105
|
+
SELECT logicalrelid::regclass AS table_name,
|
106
|
+
column_to_column_name(logicalrelid, partkey) AS dist_col_name
|
107
|
+
FROM pg_dist_partition
|
108
|
+
SQL
|
109
|
+
@connection.execute(query_to_execute).to_h do |v|
|
110
|
+
[v['table_name'], v['dist_col_name']]
|
111
|
+
end
|
112
|
+
else
|
113
|
+
{}
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Support for create_distributed_table & create_reference_table
|
118
|
+
alias table_without_citus table
|
119
|
+
|
120
|
+
def table(table, stream)
|
121
|
+
table_without_citus(table, stream)
|
122
|
+
table_name = remove_prefix_and_suffix(table)
|
123
|
+
distribution_column = @distribution_columns[table_name]
|
124
|
+
if distribution_column
|
125
|
+
stream.puts " create_distributed_table(#{table_name.inspect}, #{distribution_column.inspect})"
|
126
|
+
stream.puts
|
127
|
+
elsif @distribution_columns.key?(table_name)
|
128
|
+
stream.puts " create_reference_table(#{table_name.inspect})"
|
129
|
+
stream.puts
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -1,18 +1,32 @@
|
|
1
|
+
require_relative './multi_tenant'
|
2
|
+
|
1
3
|
module MultiTenant
|
4
|
+
# Extension to the model to allow scoping of models to the current tenant. This is done by adding
|
5
|
+
# the multitenant method to the models that need to be scoped. This method is called in the
|
6
|
+
# model declaration.
|
7
|
+
# Adds scoped_by_tenant? partition_key, primary_key and inherited methods to the model
|
2
8
|
module ModelExtensionsClassMethods
|
3
9
|
DEFAULT_ID_FIELD = 'id'.freeze
|
4
|
-
|
10
|
+
# executes when multi_tenant method is called in the model. This method adds the following
|
11
|
+
# methods to the model that calls it.
|
12
|
+
# scoped_by_tenant? - returns true if the model is scoped by tenant
|
13
|
+
# partition_key - returns the partition key for the model
|
14
|
+
# primary_key - returns the primary key for the model
|
15
|
+
#
|
5
16
|
def multi_tenant(tenant_name, options = {})
|
6
|
-
if to_s.underscore.to_sym == tenant_name
|
17
|
+
if to_s.underscore.to_sym == tenant_name || (!table_name.nil? && table_name.singularize.to_sym == tenant_name)
|
7
18
|
unless MultiTenant.with_write_only_mode_enabled?
|
8
19
|
# This is the tenant model itself. Workaround for https://github.com/citusdata/citus/issues/687
|
9
|
-
before_create
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
20
|
+
before_create lambda {
|
21
|
+
id = if self.class.columns_hash[self.class.primary_key].type == :uuid
|
22
|
+
SecureRandom.uuid
|
23
|
+
else
|
24
|
+
self.class.connection.select_value(
|
25
|
+
"SELECT nextval('#{self.class.table_name}_#{self.class.primary_key}_seq'::regclass)"
|
26
|
+
)
|
27
|
+
end
|
28
|
+
self.id ||= id
|
29
|
+
}
|
16
30
|
end
|
17
31
|
else
|
18
32
|
class << self
|
@@ -22,24 +36,23 @@ module MultiTenant
|
|
22
36
|
|
23
37
|
# Allow partition_key to be set from a superclass if not already set in this class
|
24
38
|
def partition_key
|
25
|
-
@partition_key ||= ancestors.detect{ |k| k.instance_variable_get(:@partition_key) }
|
26
|
-
|
39
|
+
@partition_key ||= ancestors.detect { |k| k.instance_variable_get(:@partition_key) }
|
40
|
+
.try(:instance_variable_get, :@partition_key)
|
27
41
|
end
|
28
42
|
|
29
43
|
# Avoid primary_key errors when using composite primary keys (e.g. id, tenant_id)
|
30
44
|
def primary_key
|
31
|
-
|
45
|
+
if defined?(PRIMARY_KEY_NOT_SET) ? !PRIMARY_KEY_NOT_SET.equal?(@primary_key) : @primary_key
|
46
|
+
return @primary_key
|
47
|
+
end
|
32
48
|
|
33
49
|
primary_object_keys = Array.wrap(connection.schema_cache.primary_keys(table_name)) - [partition_key]
|
34
50
|
|
35
|
-
if primary_object_keys.size == 1
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
# table without a primary key and DEFAULT_ID_FIELD is not present in the table
|
41
|
-
@primary_key = nil
|
42
|
-
end
|
51
|
+
@primary_key = if primary_object_keys.size == 1
|
52
|
+
primary_object_keys.first
|
53
|
+
elsif connection.schema_cache.columns_hash(table_name).include? DEFAULT_ID_FIELD
|
54
|
+
DEFAULT_ID_FIELD
|
55
|
+
end
|
43
56
|
end
|
44
57
|
|
45
58
|
def inherited(subclass)
|
@@ -54,43 +67,56 @@ module MultiTenant
|
|
54
67
|
partition_key = @partition_key
|
55
68
|
|
56
69
|
# Create an implicit belongs_to association only if tenant class exists
|
57
|
-
if MultiTenant.tenant_klass_defined?(tenant_name)
|
58
|
-
belongs_to tenant_name, **options.slice(:class_name, :inverse_of, :optional)
|
70
|
+
if MultiTenant.tenant_klass_defined?(tenant_name, options)
|
71
|
+
belongs_to tenant_name, **options.slice(:class_name, :inverse_of, :optional)
|
72
|
+
.merge(foreign_key: options[:partition_key])
|
59
73
|
end
|
60
74
|
|
61
75
|
# New instances should have the tenant set
|
62
|
-
after_initialize
|
76
|
+
after_initialize proc { |record|
|
63
77
|
if MultiTenant.current_tenant_id &&
|
64
|
-
|
78
|
+
(!record.attribute_present?(partition_key) || record.public_send(partition_key.to_sym).nil?)
|
65
79
|
record.public_send("#{partition_key}=".to_sym, MultiTenant.current_tenant_id)
|
66
80
|
end
|
67
81
|
}
|
68
82
|
|
83
|
+
# Below block adds the following methods to the model that calls it.
|
84
|
+
# partition_key= - returns the partition key for the model.class << self 'partition' method defined above
|
85
|
+
# is the getter method. Here, there is additional check to assure that the tenant id is not changed once set
|
86
|
+
# tenant_name- returns the name of the tenant model. Its setter and getter methods defined separately
|
87
|
+
# Getter checks for the tenant association and if it is not loaded, returns the current tenant id set
|
88
|
+
# in the MultiTenant module
|
69
89
|
to_include = Module.new do
|
70
90
|
define_method "#{partition_key}=" do |tenant_id|
|
71
|
-
write_attribute(
|
91
|
+
write_attribute(partition_key.to_s, tenant_id)
|
72
92
|
|
73
93
|
# Rails 5 `attribute_will_change!` uses the attribute-method-call rather than `read_attribute`
|
74
94
|
# and will raise ActiveModel::MissingAttributeError if that column was not selected.
|
75
95
|
# This is rescued as NoMethodError and in MRI attribute_was is assigned an arbitrary Object
|
76
|
-
# This is still true after the Rails 5.2 refactor
|
77
96
|
was = send("#{partition_key}_was")
|
78
|
-
was_nil_or_skipped = was.nil? || was.
|
97
|
+
was_nil_or_skipped = was.nil? || was.instance_of?(Object)
|
98
|
+
|
99
|
+
if send("#{partition_key}_changed?") && persisted? && !was_nil_or_skipped
|
100
|
+
raise MultiTenant::TenantIsImmutable
|
101
|
+
end
|
79
102
|
|
80
|
-
raise MultiTenant::TenantIsImmutable if send("#{partition_key}_changed?") && persisted? && !was_nil_or_skipped
|
81
103
|
tenant_id
|
82
104
|
end
|
83
105
|
|
84
|
-
if MultiTenant.tenant_klass_defined?(tenant_name)
|
106
|
+
if MultiTenant.tenant_klass_defined?(tenant_name, options)
|
85
107
|
define_method "#{tenant_name}=" do |model|
|
86
108
|
super(model)
|
87
|
-
|
109
|
+
if send("#{partition_key}_changed?") && persisted? && !send("#{partition_key}_was").nil?
|
110
|
+
raise MultiTenant::TenantIsImmutable
|
111
|
+
end
|
112
|
+
|
88
113
|
model
|
89
114
|
end
|
90
115
|
|
91
|
-
define_method
|
92
|
-
if !association(tenant_name.to_sym).loaded? && !MultiTenant.current_tenant_is_id? &&
|
93
|
-
|
116
|
+
define_method tenant_name.to_s do
|
117
|
+
if !association(tenant_name.to_sym).loaded? && !MultiTenant.current_tenant_is_id? &&
|
118
|
+
MultiTenant.current_tenant_id && public_send(partition_key) == MultiTenant.current_tenant_id
|
119
|
+
MultiTenant.current_tenant
|
94
120
|
else
|
95
121
|
super()
|
96
122
|
end
|
@@ -99,23 +125,28 @@ module MultiTenant
|
|
99
125
|
end
|
100
126
|
include to_include
|
101
127
|
|
102
|
-
|
103
|
-
|
128
|
+
# Below blocks sets tenant_id for the current session with the tenant_id of the record
|
129
|
+
# If the tenant is not set for the `session.After` the save operation current session tenant is set to nil
|
130
|
+
# If tenant is set for the session, save operation is performed as it is
|
131
|
+
around_save lambda { |record, block|
|
132
|
+
record_tenant = record.attribute_was(partition_key)
|
133
|
+
if persisted? && MultiTenant.current_tenant_id.nil? && !record_tenant.nil?
|
104
134
|
MultiTenant.with(record.public_send(partition_key)) { block.call }
|
105
135
|
else
|
106
136
|
block.call
|
107
137
|
end
|
108
138
|
}
|
109
139
|
|
110
|
-
around_update
|
111
|
-
|
140
|
+
around_update lambda { |record, block|
|
141
|
+
record_tenant = record.attribute_was(partition_key)
|
142
|
+
if MultiTenant.current_tenant_id.nil? && !record_tenant.nil?
|
112
143
|
MultiTenant.with(record.public_send(partition_key)) { block.call }
|
113
144
|
else
|
114
145
|
block.call
|
115
146
|
end
|
116
147
|
}
|
117
148
|
|
118
|
-
around_destroy
|
149
|
+
around_destroy lambda { |record, block|
|
119
150
|
if MultiTenant.current_tenant_id.nil?
|
120
151
|
MultiTenant.with(record.public_send(partition_key)) { block.call }
|
121
152
|
else
|
@@ -127,12 +158,39 @@ module MultiTenant
|
|
127
158
|
end
|
128
159
|
end
|
129
160
|
|
161
|
+
# Below code block is executed on Model, Associations and CollectionProxy objects
|
162
|
+
# when ActiveRecord is loaded and decorates defined methods with MultiTenant.with function.
|
163
|
+
# Additionally, adds aliases for some operators.
|
130
164
|
ActiveSupport.on_load(:active_record) do |base|
|
131
165
|
base.extend MultiTenant::ModelExtensionsClassMethods
|
166
|
+
|
167
|
+
# Ensure we have current_tenant_id in where clause when a cached ActiveRecord instance is being reloaded,
|
168
|
+
# or update_columns without callbacks is called
|
169
|
+
MultiTenant.wrap_methods(ActiveRecord::Base, 'self', :delete, :reload, :update_columns)
|
170
|
+
|
171
|
+
# Any queuries fired for fetching a singular association have the correct current_tenant_id in WHERE clause
|
172
|
+
# reload is called anytime any record's association is accessed
|
173
|
+
MultiTenant.wrap_methods(ActiveRecord::Associations::Association, 'owner', :reload)
|
174
|
+
|
175
|
+
# For collection associations, we need to wrap multiple methods in returned proxy so that
|
176
|
+
# any queries have the correct current_tenant_id in WHERE clause
|
177
|
+
ActiveRecord::Associations::CollectionProxy.alias_method \
|
178
|
+
:equals_mt, :== # Hack to prevent syntax error due to invalid method name
|
179
|
+
ActiveRecord::Associations::CollectionProxy.alias_method \
|
180
|
+
:append_mt, :<< # Hack to prevent syntax error due to invalid method name
|
181
|
+
MultiTenant.wrap_methods(ActiveRecord::Associations::CollectionProxy, '@association.owner',
|
182
|
+
:find, :last, :take, :build, :create, :create!, :replace, :delete_all,
|
183
|
+
:destroy_all, :delete, :destroy, :calculate, :pluck, :size, :empty?, :include?, :equals_mt,
|
184
|
+
:records, :append_mt, :find_nth_with_limit, :find_nth_from_last, :null_scope?,
|
185
|
+
:find_from_target?, :exec_queries)
|
186
|
+
ActiveRecord::Associations::CollectionProxy.alias_method :==, :equals_mt
|
187
|
+
ActiveRecord::Associations::CollectionProxy.alias_method :<<, :append_mt
|
132
188
|
end
|
133
189
|
|
190
|
+
# skips statement caching for classes that is Multi-tenant or has a multi-tenant relation
|
134
191
|
class ActiveRecord::Associations::Association
|
135
192
|
alias skip_statement_cache_orig skip_statement_cache?
|
193
|
+
|
136
194
|
def skip_statement_cache?(*scope)
|
137
195
|
return true if klass.respond_to?(:scoped_by_tenant?) && klass.scoped_by_tenant?
|
138
196
|
|
@@ -5,27 +5,38 @@ module MultiTenant
|
|
5
5
|
attribute :tenant
|
6
6
|
end
|
7
7
|
|
8
|
-
def self.tenant_klass_defined?(tenant_name)
|
9
|
-
|
8
|
+
def self.tenant_klass_defined?(tenant_name, options = {})
|
9
|
+
class_name = if options[:class_name].present?
|
10
|
+
options[:class_name]
|
11
|
+
else
|
12
|
+
tenant_name.to_s.classify
|
13
|
+
end
|
14
|
+
!!class_name.safe_constantize
|
10
15
|
end
|
11
16
|
|
12
17
|
def self.partition_key(tenant_name)
|
13
|
-
"#{tenant_name
|
18
|
+
"#{tenant_name}_id"
|
14
19
|
end
|
15
20
|
|
21
|
+
# rubocop:disable Style/ClassVars
|
16
22
|
# In some cases we only have an ID - if defined we'll return the default tenant class in such cases
|
17
|
-
def self.default_tenant_class=(tenant_class)
|
18
|
-
|
23
|
+
def self.default_tenant_class=(tenant_class)
|
24
|
+
@@default_tenant_class = tenant_class
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.default_tenant_class
|
28
|
+
@@default_tenant_class ||= nil
|
29
|
+
end
|
19
30
|
|
20
31
|
# Write-only Mode - this only adds the tenant_id to new records, but doesn't
|
21
32
|
# require its presence for SELECTs/UPDATEs/DELETEs
|
22
|
-
def self.enable_write_only_mode
|
23
|
-
|
33
|
+
def self.enable_write_only_mode
|
34
|
+
@@enable_write_only_mode = true
|
35
|
+
end
|
24
36
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
def self.with_lock_workaround_enabled?; @@enable_with_lock_workaround; end
|
37
|
+
def self.with_write_only_mode_enabled?
|
38
|
+
@@enable_write_only_mode ||= false
|
39
|
+
end
|
29
40
|
|
30
41
|
# Registry that maps table names to models (used by the query rewriter)
|
31
42
|
def self.register_multi_tenant_model(model_klass)
|
@@ -38,21 +49,23 @@ module MultiTenant
|
|
38
49
|
def self.multi_tenant_model_for_table(table_name)
|
39
50
|
@@multi_tenant_models ||= []
|
40
51
|
|
41
|
-
|
42
|
-
@@multi_tenant_model_table_names = @@multi_tenant_models.map
|
52
|
+
unless defined?(@@multi_tenant_model_table_names)
|
53
|
+
@@multi_tenant_model_table_names = @@multi_tenant_models.map do |model|
|
43
54
|
[model.table_name, model] if model.table_name
|
44
|
-
|
55
|
+
end.compact.to_h
|
45
56
|
end
|
46
57
|
|
47
58
|
@@multi_tenant_model_table_names[table_name.to_s]
|
59
|
+
# rubocop:enable Style/ClassVars
|
48
60
|
end
|
49
61
|
|
50
62
|
def self.multi_tenant_model_for_arel(arel)
|
51
63
|
return nil unless arel.respond_to?(:ast)
|
64
|
+
|
52
65
|
if arel.ast.relation.is_a? Arel::Nodes::JoinSource
|
53
|
-
MultiTenant.multi_tenant_model_for_table(arel.ast.relation.left
|
66
|
+
MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation.left))
|
54
67
|
else
|
55
|
-
MultiTenant.multi_tenant_model_for_table(arel.ast.relation
|
68
|
+
MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation))
|
56
69
|
end
|
57
70
|
end
|
58
71
|
|
@@ -74,7 +87,7 @@ module MultiTenant
|
|
74
87
|
|
75
88
|
def self.current_tenant_class
|
76
89
|
if current_tenant_is_id?
|
77
|
-
MultiTenant.default_tenant_class ||
|
90
|
+
MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
|
78
91
|
elsif current_tenant
|
79
92
|
MultiTenant.current_tenant.class.name
|
80
93
|
end
|
@@ -83,35 +96,81 @@ module MultiTenant
|
|
83
96
|
def self.load_current_tenant!
|
84
97
|
return MultiTenant.current_tenant if MultiTenant.current_tenant && !current_tenant_is_id?
|
85
98
|
raise 'MultiTenant.current_tenant must be set to load' if MultiTenant.current_tenant.nil?
|
86
|
-
|
99
|
+
|
100
|
+
klass = MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
|
87
101
|
self.current_tenant = klass.find(MultiTenant.current_tenant_id)
|
88
102
|
end
|
89
103
|
|
90
104
|
def self.with(tenant, &block)
|
91
|
-
return block.call if
|
92
|
-
|
105
|
+
return block.call if current_tenant == tenant
|
106
|
+
|
107
|
+
old_tenant = current_tenant
|
93
108
|
begin
|
94
109
|
self.current_tenant = tenant
|
95
|
-
|
110
|
+
block.call
|
96
111
|
ensure
|
97
112
|
self.current_tenant = old_tenant
|
98
113
|
end
|
99
114
|
end
|
100
115
|
|
101
116
|
def self.without(&block)
|
102
|
-
return block.call if
|
103
|
-
|
117
|
+
return block.call if current_tenant.nil?
|
118
|
+
|
119
|
+
old_tenant = current_tenant
|
104
120
|
begin
|
105
121
|
self.current_tenant = nil
|
106
|
-
|
122
|
+
block.call
|
107
123
|
ensure
|
108
124
|
self.current_tenant = old_tenant
|
109
125
|
end
|
110
126
|
end
|
111
127
|
|
128
|
+
# Wrap calls to any of `method_names` on an instance Class `klass` with MultiTenant.with
|
129
|
+
# when `'owner'` (evaluated in context of the klass instance) is a ActiveRecord model instance that is multi-tenant
|
130
|
+
# Instruments the methods provided with previously set Multitenant parameters
|
131
|
+
# In Ruby 2 using splat (*) operator with `&block` is not supported, so we need to use `method(...)` syntax
|
132
|
+
# TODO: Could not understand the use of owner here. Need to check
|
133
|
+
if Gem::Version.create(RUBY_VERSION) < Gem::Version.new('3.0.0')
|
134
|
+
def self.wrap_methods(klass, owner, *method_names)
|
135
|
+
method_names.each do |method_name|
|
136
|
+
original_method_name = :"_mt_original_#{method_name}"
|
137
|
+
klass.class_eval <<-CODE, __FILE__, __LINE__ + 1
|
138
|
+
alias_method :#{original_method_name}, :#{method_name}
|
139
|
+
def #{method_name}(*args, &block)
|
140
|
+
if MultiTenant.multi_tenant_model_for_table(#{owner}.class.table_name).present? && #{owner}.persisted? && MultiTenant.current_tenant_id.nil? && #{owner}.class.respond_to?(:partition_key) && #{owner}.attributes.include?(#{owner}.class.partition_key)
|
141
|
+
MultiTenant.with(#{owner}.public_send(#{owner}.class.partition_key)) { #{original_method_name}(*args, &block) }
|
142
|
+
else
|
143
|
+
#{original_method_name}(*args, &block)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
CODE
|
147
|
+
end
|
148
|
+
end
|
149
|
+
else
|
150
|
+
def self.wrap_methods(klass, owner, *method_names)
|
151
|
+
method_names.each do |method_name|
|
152
|
+
original_method_name = :"_mt_original_#{method_name}"
|
153
|
+
klass.class_eval <<-CODE, __FILE__, __LINE__ + 1
|
154
|
+
alias_method :#{original_method_name}, :#{method_name}
|
155
|
+
def #{method_name}(...)
|
156
|
+
if MultiTenant.multi_tenant_model_for_table(#{owner}.class.table_name).present? && #{owner}.persisted? && MultiTenant.current_tenant_id.nil? && #{owner}.class.respond_to?(:partition_key) && #{owner}.attributes.include?(#{owner}.class.partition_key)
|
157
|
+
MultiTenant.with(#{owner}.public_send(#{owner}.class.partition_key)) { #{original_method_name}(...) }
|
158
|
+
else
|
159
|
+
#{original_method_name}(...)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
CODE
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
112
167
|
# Preserve backward compatibility for people using .with_id
|
113
168
|
singleton_class.send(:alias_method, :with_id, :with)
|
114
169
|
|
170
|
+
# This exception is raised when a there is an attempt to change tenant
|
115
171
|
class TenantIsImmutable < StandardError
|
116
172
|
end
|
173
|
+
|
174
|
+
class MissingTenantError < StandardError
|
175
|
+
end
|
117
176
|
end
|
@@ -1,18 +1,34 @@
|
|
1
1
|
# Add generic warning when queries fail and there is no tenant set
|
2
|
+
# To handle this case, a QueryMonitor hook is created and registered
|
3
|
+
# to sql.active_record. This hook will log a warning when a query fails
|
4
|
+
# This hook is executed after the query is executed.
|
2
5
|
module MultiTenant
|
6
|
+
# rubocop:disable Style/ClassVars
|
3
7
|
# Option to enable query monitor
|
4
8
|
@@enable_query_monitor = false
|
5
|
-
def self.enable_query_monitor; @@enable_query_monitor = true; end
|
6
|
-
def self.query_monitor_enabled?; @@enable_query_monitor; end
|
7
9
|
|
10
|
+
def self.enable_query_monitor
|
11
|
+
@@enable_query_monitor = true
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.query_monitor_enabled?
|
15
|
+
@@enable_query_monitor
|
16
|
+
end
|
17
|
+
|
18
|
+
# rubocop:enable Style/ClassVars
|
19
|
+
# QueryMonitor class to log a warning when a query fails and there is no tenant set
|
20
|
+
# start and finish methods are required to be register sql.active_record hook
|
8
21
|
class QueryMonitor
|
9
|
-
def start(
|
10
|
-
|
22
|
+
def start(_name, _id, _payload) end
|
23
|
+
|
24
|
+
def finish(_name, _id, payload)
|
11
25
|
return unless MultiTenant.query_monitor_enabled?
|
26
|
+
|
12
27
|
return unless payload[:exception].present? && MultiTenant.current_tenant_id.nil?
|
28
|
+
|
13
29
|
Rails.logger.info 'WARNING: Tenant not present - make sure to add MultiTenant.with(tenant) { ... }'
|
14
30
|
end
|
15
31
|
end
|
16
32
|
end
|
17
|
-
|
33
|
+
# Actual code to register the hook.
|
18
34
|
ActiveSupport::Notifications.subscribe('sql.active_record', MultiTenant::QueryMonitor.new)
|