activerecord-multi-tenant 1.2.0 → 2.4.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/active-record-multi-tenant-tests.yml +83 -0
  3. data/.gitignore +6 -0
  4. data/.readthedocs.yaml +15 -0
  5. data/.rspec +0 -0
  6. data/.rubocop.yml +51 -0
  7. data/Appraisals +6 -22
  8. data/CHANGELOG.md +51 -0
  9. data/Gemfile +3 -1
  10. data/LICENSE +0 -0
  11. data/README.md +3 -2
  12. data/Rakefile +1 -1
  13. data/activerecord-multi-tenant.gemspec +28 -23
  14. data/docker-compose.yml +24 -18
  15. data/docs/.gitignore +3 -0
  16. data/docs/Makefile +28 -0
  17. data/docs/api-reference.sh +10 -0
  18. data/docs/requirements.in +4 -0
  19. data/docs/requirements.txt +62 -0
  20. data/docs/source/_static/api-reference/ActiveRecord/Associations/Association.html +285 -0
  21. data/docs/source/_static/api-reference/ActiveRecord/Associations/ClassMethods.html +255 -0
  22. data/docs/source/_static/api-reference/ActiveRecord/Associations.html +117 -0
  23. data/docs/source/_static/api-reference/ActiveRecord/ConnectionAdapters/SchemaStatements.html +232 -0
  24. data/docs/source/_static/api-reference/ActiveRecord/ConnectionAdapters.html +126 -0
  25. data/docs/source/_static/api-reference/ActiveRecord/QueryMethods.html +336 -0
  26. data/docs/source/_static/api-reference/ActiveRecord/SchemaDumper.html +121 -0
  27. data/docs/source/_static/api-reference/ActiveRecord.html +130 -0
  28. data/docs/source/_static/api-reference/MultiTenant/ArelTenantVisitor.html +755 -0
  29. data/docs/source/_static/api-reference/MultiTenant/ArelVisitorsDepthFirst.html +208 -0
  30. data/docs/source/_static/api-reference/MultiTenant/BaseTenantEnforcementClause.html +462 -0
  31. data/docs/source/_static/api-reference/MultiTenant/Context.html +659 -0
  32. data/docs/source/_static/api-reference/MultiTenant/ControllerExtensions.html +202 -0
  33. data/docs/source/_static/api-reference/MultiTenant/CopyFromClient.html +186 -0
  34. data/docs/source/_static/api-reference/MultiTenant/CopyFromClientHelper.html +362 -0
  35. data/docs/source/_static/api-reference/MultiTenant/Current.html +124 -0
  36. data/docs/source/_static/api-reference/MultiTenant/DatabaseStatements.html +366 -0
  37. data/docs/source/_static/api-reference/MultiTenant/FastTruncate.html +226 -0
  38. data/docs/source/_static/api-reference/MultiTenant/MigrationExtensions.html +554 -0
  39. data/docs/source/_static/api-reference/MultiTenant/MissingTenantError.html +124 -0
  40. data/docs/source/_static/api-reference/MultiTenant/ModelExtensionsClassMethods.html +492 -0
  41. data/docs/source/_static/api-reference/MultiTenant/QueryMonitor.html +257 -0
  42. data/docs/source/_static/api-reference/MultiTenant/Table.html +419 -0
  43. data/docs/source/_static/api-reference/MultiTenant/TenantEnforcementClause.html +148 -0
  44. data/docs/source/_static/api-reference/MultiTenant/TenantIsImmutable.html +135 -0
  45. data/docs/source/_static/api-reference/MultiTenant/TenantJoinEnforcementClause.html +310 -0
  46. data/docs/source/_static/api-reference/MultiTenant/TenantValueVisitor.html +239 -0
  47. data/docs/source/_static/api-reference/MultiTenant.html +1454 -0
  48. data/docs/source/_static/api-reference/MultiTenantFindBy.html +180 -0
  49. data/docs/source/_static/api-reference/Sidekiq/Client.html +302 -0
  50. data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant/Client.html +217 -0
  51. data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant/Server.html +219 -0
  52. data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant.html +126 -0
  53. data/docs/source/_static/api-reference/Sidekiq.html +126 -0
  54. data/docs/source/_static/api-reference/_index.html +399 -0
  55. data/docs/source/_static/api-reference/class_list.html +51 -0
  56. data/docs/source/_static/api-reference/css/common.css +1 -0
  57. data/docs/source/_static/api-reference/css/full_list.css +58 -0
  58. data/docs/source/_static/api-reference/css/style.css +497 -0
  59. data/docs/source/_static/api-reference/file.README.html +167 -0
  60. data/docs/source/_static/api-reference/file_list.html +56 -0
  61. data/docs/source/_static/api-reference/frames.html +17 -0
  62. data/docs/source/_static/api-reference/index.html +167 -0
  63. data/docs/source/_static/api-reference/js/app.js +314 -0
  64. data/docs/source/_static/api-reference/js/full_list.js +216 -0
  65. data/docs/source/_static/api-reference/js/jquery.js +4 -0
  66. data/docs/source/_static/api-reference/method_list.html +715 -0
  67. data/docs/source/_static/api-reference/top-level-namespace.html +126 -0
  68. data/docs/source/_templates/.gitignore +4 -0
  69. data/docs/source/api-reference.rst +8 -0
  70. data/docs/source/appendix.rst +26 -0
  71. data/docs/source/changelog.rst +8 -0
  72. data/docs/source/community-and-support.rst +26 -0
  73. data/docs/source/conf.py +30 -0
  74. data/docs/source/contributing.rst +70 -0
  75. data/docs/source/getting-started.rst +37 -0
  76. data/docs/source/guides-and-tutorials.rst +129 -0
  77. data/docs/source/index.rst +54 -0
  78. data/docs/source/introduction.rst +33 -0
  79. data/docs/source/license.rst +22 -0
  80. data/docs/source/troubleshooting.rst +41 -0
  81. data/docs/source/usage-guide.rst +59 -0
  82. data/lib/activerecord-multi-tenant/arel_visitors_depth_first.rb +183 -174
  83. data/lib/activerecord-multi-tenant/controller_extensions.rb +15 -4
  84. data/lib/activerecord-multi-tenant/copy_from_client.rb +4 -0
  85. data/lib/activerecord-multi-tenant/fast_truncate.rb +4 -2
  86. data/lib/activerecord-multi-tenant/habtm.rb +50 -0
  87. data/lib/activerecord-multi-tenant/migrations.rb +87 -10
  88. data/lib/activerecord-multi-tenant/model_extensions.rb +98 -34
  89. data/lib/activerecord-multi-tenant/multi_tenant.rb +102 -29
  90. data/lib/activerecord-multi-tenant/query_monitor.rb +21 -5
  91. data/lib/activerecord-multi-tenant/query_rewriter.rb +122 -91
  92. data/lib/activerecord-multi-tenant/sidekiq.rb +46 -19
  93. data/lib/activerecord-multi-tenant/table_node.rb +13 -0
  94. data/lib/activerecord-multi-tenant/version.rb +1 -1
  95. data/lib/activerecord-multi-tenant.rb +3 -13
  96. data/lib/activerecord_multi_tenant.rb +13 -0
  97. data/spec/activerecord-multi-tenant/associations_spec.rb +42 -0
  98. data/spec/activerecord-multi-tenant/controller_extensions_spec.rb +3 -2
  99. data/spec/activerecord-multi-tenant/fast_truncate_spec.rb +8 -6
  100. data/spec/activerecord-multi-tenant/model_extensions_spec.rb +347 -143
  101. data/spec/activerecord-multi-tenant/multi_tenant_spec.rb +69 -13
  102. data/spec/activerecord-multi-tenant/query_rewriter_spec.rb +60 -59
  103. data/spec/activerecord-multi-tenant/record_callback_spec.rb +0 -0
  104. data/spec/activerecord-multi-tenant/record_finding_spec.rb +11 -11
  105. data/spec/activerecord-multi-tenant/record_modifications_spec.rb +23 -4
  106. data/spec/activerecord-multi-tenant/sidekiq_spec.rb +10 -10
  107. data/spec/database.yml +0 -0
  108. data/spec/schema.rb +43 -2
  109. data/spec/spec_helper.rb +52 -16
  110. data/spec/support/format_sql.rb +20 -0
  111. metadata +126 -36
  112. data/.github/workflows/CI.yml +0 -63
  113. data/gemfiles/.bundle/config +0 -2
  114. data/gemfiles/active_record_5.2.gemfile +0 -16
  115. data/gemfiles/active_record_6.0.gemfile +0 -8
  116. data/gemfiles/active_record_6.1.gemfile +0 -8
  117. data/gemfiles/active_record_7.0.gemfile +0 -8
  118. data/gemfiles/rails_5.2.gemfile +0 -16
  119. data/gemfiles/rails_6.0.gemfile +0 -8
  120. data/gemfiles/rails_6.1.gemfile +0 -8
  121. data/gemfiles/rails_7.0.gemfile +0 -8
  122. data/lib/activerecord-multi-tenant/persistence_extension.rb +0 -13
  123. data/lib/activerecord-multi-tenant/with_lock.rb +0 -15
  124. 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
- execute "SELECT create_distributed_table($$#{table_name}$$, $$#{partition_key}$$)"
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
- execute "SELECT create_reference_table($$#{table_name}$$)"
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 # 6.1 and newer
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 == "invalid tuple number 0"
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 :orig_create_table :create_table
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,12 +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 -> { self.id ||= self.class.connection.select_value("SELECT nextval('" + [self.class.table_name, self.class.primary_key, 'seq'].join('_') + "'::regclass)") }
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
+ }
10
30
  end
11
31
  else
12
32
  class << self
@@ -16,75 +36,87 @@ module MultiTenant
16
36
 
17
37
  # Allow partition_key to be set from a superclass if not already set in this class
18
38
  def partition_key
19
- @partition_key ||= ancestors.detect{ |k| k.instance_variable_get(:@partition_key) }
20
- .try(:instance_variable_get, :@partition_key)
39
+ @partition_key ||= ancestors.detect { |k| k.instance_variable_get(:@partition_key) }
40
+ .try(:instance_variable_get, :@partition_key)
21
41
  end
22
42
 
23
43
  # Avoid primary_key errors when using composite primary keys (e.g. id, tenant_id)
24
44
  def primary_key
25
- return @primary_key if @primary_key
45
+ if defined?(PRIMARY_KEY_NOT_SET) ? !PRIMARY_KEY_NOT_SET.equal?(@primary_key) : @primary_key
46
+ return @primary_key
47
+ end
26
48
 
27
49
  primary_object_keys = Array.wrap(connection.schema_cache.primary_keys(table_name)) - [partition_key]
28
50
 
29
- if primary_object_keys.size == 1
30
- @primary_key = primary_object_keys.first
31
- elsif connection.schema_cache.columns_hash(table_name).include? DEFAULT_ID_FIELD
32
- @primary_key = DEFAULT_ID_FIELD
33
- else
34
- # table without a primary key and DEFAULT_ID_FIELD is not present in the table
35
- @primary_key = nil
36
- 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
37
56
  end
38
57
 
39
58
  def inherited(subclass)
40
59
  super
41
- MultiTenant.register_multi_tenant_model(subclass.table_name, subclass) if subclass.table_name
60
+ MultiTenant.register_multi_tenant_model(subclass)
42
61
  end
43
62
  end
44
63
 
45
- MultiTenant.register_multi_tenant_model(table_name, self) if table_name
64
+ MultiTenant.register_multi_tenant_model(self)
46
65
 
47
66
  @partition_key = options[:partition_key] || MultiTenant.partition_key(tenant_name)
48
67
  partition_key = @partition_key
49
68
 
50
69
  # Create an implicit belongs_to association only if tenant class exists
51
- if MultiTenant.tenant_klass_defined?(tenant_name)
52
- belongs_to tenant_name, **options.slice(:class_name, :inverse_of).merge(foreign_key: options[:partition_key])
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])
53
73
  end
54
74
 
55
75
  # New instances should have the tenant set
56
- after_initialize Proc.new { |record|
76
+ after_initialize proc { |record|
57
77
  if MultiTenant.current_tenant_id &&
58
- (!record.attribute_present?(partition_key) || record.public_send(partition_key.to_sym).nil?)
78
+ (!record.attribute_present?(partition_key) || record.public_send(partition_key.to_sym).nil?)
59
79
  record.public_send("#{partition_key}=".to_sym, MultiTenant.current_tenant_id)
60
80
  end
61
81
  }
62
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
63
89
  to_include = Module.new do
64
90
  define_method "#{partition_key}=" do |tenant_id|
65
- write_attribute("#{partition_key}", tenant_id)
91
+ write_attribute(partition_key.to_s, tenant_id)
66
92
 
67
93
  # Rails 5 `attribute_will_change!` uses the attribute-method-call rather than `read_attribute`
68
94
  # and will raise ActiveModel::MissingAttributeError if that column was not selected.
69
95
  # This is rescued as NoMethodError and in MRI attribute_was is assigned an arbitrary Object
70
- # This is still true after the Rails 5.2 refactor
71
96
  was = send("#{partition_key}_was")
72
- was_nil_or_skipped = was.nil? || was.class == Object
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
73
102
 
74
- raise MultiTenant::TenantIsImmutable if send("#{partition_key}_changed?") && persisted? && !was_nil_or_skipped
75
103
  tenant_id
76
104
  end
77
105
 
78
- if MultiTenant.tenant_klass_defined?(tenant_name)
106
+ if MultiTenant.tenant_klass_defined?(tenant_name, options)
79
107
  define_method "#{tenant_name}=" do |model|
80
108
  super(model)
81
- raise MultiTenant::TenantIsImmutable if send("#{partition_key}_changed?") && persisted? && !send("#{partition_key}_was").nil?
109
+ if send("#{partition_key}_changed?") && persisted? && !send("#{partition_key}_was").nil?
110
+ raise MultiTenant::TenantIsImmutable
111
+ end
112
+
82
113
  model
83
114
  end
84
115
 
85
- define_method "#{tenant_name}" do
86
- if !association(tenant_name.to_sym).loaded? && !MultiTenant.current_tenant_is_id? && MultiTenant.current_tenant_id && public_send(partition_key) == MultiTenant.current_tenant_id
87
- return MultiTenant.current_tenant
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
88
120
  else
89
121
  super()
90
122
  end
@@ -93,23 +125,28 @@ module MultiTenant
93
125
  end
94
126
  include to_include
95
127
 
96
- around_save -> (record, block) {
97
- if persisted? && MultiTenant.current_tenant_id.nil?
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?
98
134
  MultiTenant.with(record.public_send(partition_key)) { block.call }
99
135
  else
100
136
  block.call
101
137
  end
102
138
  }
103
139
 
104
- around_update -> (record, block) {
105
- if MultiTenant.current_tenant_id.nil?
140
+ around_update lambda { |record, block|
141
+ record_tenant = record.attribute_was(partition_key)
142
+ if MultiTenant.current_tenant_id.nil? && !record_tenant.nil?
106
143
  MultiTenant.with(record.public_send(partition_key)) { block.call }
107
144
  else
108
145
  block.call
109
146
  end
110
147
  }
111
148
 
112
- around_destroy -> (record, block) {
149
+ around_destroy lambda { |record, block|
113
150
  if MultiTenant.current_tenant_id.nil?
114
151
  MultiTenant.with(record.public_send(partition_key)) { block.call }
115
152
  else
@@ -121,12 +158,39 @@ module MultiTenant
121
158
  end
122
159
  end
123
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.
124
164
  ActiveSupport.on_load(:active_record) do |base|
125
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
126
188
  end
127
189
 
190
+ # skips statement caching for classes that is Multi-tenant or has a multi-tenant relation
128
191
  class ActiveRecord::Associations::Association
129
192
  alias skip_statement_cache_orig skip_statement_cache?
193
+
130
194
  def skip_statement_cache?(*scope)
131
195
  return true if klass.respond_to?(:scoped_by_tenant?) && klass.scoped_by_tenant?
132
196
 
@@ -1,53 +1,80 @@
1
- require 'request_store'
1
+ require 'active_support/current_attributes'
2
2
 
3
3
  module MultiTenant
4
- def self.tenant_klass_defined?(tenant_name)
5
- !!tenant_name.to_s.classify.safe_constantize
4
+ class Current < ::ActiveSupport::CurrentAttributes
5
+ attribute :tenant
6
+ end
7
+
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
6
15
  end
7
16
 
8
17
  def self.partition_key(tenant_name)
9
- "#{tenant_name.to_s}_id"
18
+ "#{tenant_name}_id"
10
19
  end
11
20
 
21
+ # rubocop:disable Style/ClassVars
12
22
  # In some cases we only have an ID - if defined we'll return the default tenant class in such cases
13
- def self.default_tenant_class=(tenant_class); @@default_tenant_class = tenant_class; end
14
- def self.default_tenant_class; @@default_tenant_class ||= nil; end
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
15
30
 
16
31
  # Write-only Mode - this only adds the tenant_id to new records, but doesn't
17
32
  # require its presence for SELECTs/UPDATEs/DELETEs
18
- def self.enable_write_only_mode; @@enable_write_only_mode = true; end
19
- def self.with_write_only_mode_enabled?; @@enable_write_only_mode ||= false; end
33
+ def self.enable_write_only_mode
34
+ @@enable_write_only_mode = true
35
+ end
20
36
 
21
- # Workaroud to make "with_lock" work until https://github.com/citusdata/citus/issues/1236 is fixed
22
- @@enable_with_lock_workaround = false
23
- def self.enable_with_lock_workaround; @@enable_with_lock_workaround = true; end
24
- 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
25
40
 
26
41
  # Registry that maps table names to models (used by the query rewriter)
27
- def self.register_multi_tenant_model(table_name, model_klass)
28
- @@multi_tenant_models ||= {}
29
- @@multi_tenant_models[table_name.to_s] = model_klass
42
+ def self.register_multi_tenant_model(model_klass)
43
+ @@multi_tenant_models ||= []
44
+ @@multi_tenant_models.push(model_klass)
45
+
46
+ remove_class_variable(:@@multi_tenant_model_table_names) if defined?(@@multi_tenant_model_table_names)
30
47
  end
48
+
31
49
  def self.multi_tenant_model_for_table(table_name)
32
- @@multi_tenant_models ||= {}
33
- @@multi_tenant_models[table_name.to_s]
50
+ @@multi_tenant_models ||= []
51
+
52
+ unless defined?(@@multi_tenant_model_table_names)
53
+ @@multi_tenant_model_table_names = @@multi_tenant_models.map do |model|
54
+ [model.table_name, model] if model.table_name
55
+ end.compact.to_h
56
+ end
57
+
58
+ @@multi_tenant_model_table_names[table_name.to_s]
59
+ # rubocop:enable Style/ClassVars
34
60
  end
35
61
 
36
62
  def self.multi_tenant_model_for_arel(arel)
37
63
  return nil unless arel.respond_to?(:ast)
64
+
38
65
  if arel.ast.relation.is_a? Arel::Nodes::JoinSource
39
- MultiTenant.multi_tenant_model_for_table(arel.ast.relation.left.table_name)
66
+ MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation.left))
40
67
  else
41
- MultiTenant.multi_tenant_model_for_table(arel.ast.relation.table_name)
68
+ MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation))
42
69
  end
43
70
  end
44
71
 
45
72
  def self.current_tenant=(tenant)
46
- RequestStore.store[:current_tenant] = tenant
73
+ Current.tenant = tenant
47
74
  end
48
75
 
49
76
  def self.current_tenant
50
- RequestStore.store[:current_tenant]
77
+ Current.tenant
51
78
  end
52
79
 
53
80
  def self.current_tenant_id
@@ -60,7 +87,7 @@ module MultiTenant
60
87
 
61
88
  def self.current_tenant_class
62
89
  if current_tenant_is_id?
63
- MultiTenant.default_tenant_class || fail('Only have tenant id, and no default tenant class set')
90
+ MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
64
91
  elsif current_tenant
65
92
  MultiTenant.current_tenant.class.name
66
93
  end
@@ -69,35 +96,81 @@ module MultiTenant
69
96
  def self.load_current_tenant!
70
97
  return MultiTenant.current_tenant if MultiTenant.current_tenant && !current_tenant_is_id?
71
98
  raise 'MultiTenant.current_tenant must be set to load' if MultiTenant.current_tenant.nil?
72
- klass = MultiTenant.default_tenant_class || fail('Only have tenant id, and no default tenant class set')
99
+
100
+ klass = MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
73
101
  self.current_tenant = klass.find(MultiTenant.current_tenant_id)
74
102
  end
75
103
 
76
104
  def self.with(tenant, &block)
77
- return block.call if self.current_tenant == tenant
78
- old_tenant = self.current_tenant
105
+ return block.call if current_tenant == tenant
106
+
107
+ old_tenant = current_tenant
79
108
  begin
80
109
  self.current_tenant = tenant
81
- return block.call
110
+ block.call
82
111
  ensure
83
112
  self.current_tenant = old_tenant
84
113
  end
85
114
  end
86
115
 
87
116
  def self.without(&block)
88
- return block.call if self.current_tenant.nil?
89
- old_tenant = self.current_tenant
117
+ return block.call if current_tenant.nil?
118
+
119
+ old_tenant = current_tenant
90
120
  begin
91
121
  self.current_tenant = nil
92
- return block.call
122
+ block.call
93
123
  ensure
94
124
  self.current_tenant = old_tenant
95
125
  end
96
126
  end
97
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
+
98
167
  # Preserve backward compatibility for people using .with_id
99
168
  singleton_class.send(:alias_method, :with_id, :with)
100
169
 
170
+ # This exception is raised when a there is an attempt to change tenant
101
171
  class TenantIsImmutable < StandardError
102
172
  end
173
+
174
+ class MissingTenantError < StandardError
175
+ end
103
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(name, id, payload); end
10
- def finish(name, id, payload)
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)